220 Commits

Author SHA1 Message Date
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
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
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
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
Torsten Schulz (local)
1e65cb47da fix: fallback to npm install on npm ci failure due to lockfile drift
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 2m15s
2026-05-29 17:14:11 +02:00
Torsten Schulz (local)
ec96e21517 feat: enhance code analysis workflow with debugging information and workspace cleanup
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 4m1s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-29 17:07:36 +02:00
Torsten Schulz (local)
46f80df165 chore: update version to 1.7.0 in package-lock.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-29 17:02:33 +02:00
Torsten Schulz (local)
5c3d78245f feat: add Datenschutzerklärung and Konto löschen pages
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Created datenschutz.vue for the privacy policy with sections on data protection, responsible entity, data processing, rights, and contact information.
- Created konto-loeschen.vue for account deletion requests, detailing the process, affected data, and processing time.
- Added anonymize-playstore-screenshot.sh script for image anonymization using ImageMagick.
- Introduced playstore-assets.mjs for generating Play Store assets, including icons and feature graphics, using sharp.
- Added playstore-assets.sh script to execute the asset generation script.
2026-05-29 16:51:36 +02:00
Torsten Schulz (local)
f5045c3cf0 feat: implement production release configuration and signing for Play Store 2026-05-29 16:39:59 +02:00
Torsten Schulz (local)
696c50f0fc fix: update ResponseBody creation in ApiService mock for consistency 2026-05-29 16:13:08 +02:00
Torsten Schulz (local)
b8bdbf0a8d feat: add homepage components and API for settings and spielplan options
- Introduced new Vue components for homepage teasers: HomeLinksTeaser, HomeSpielplanTeamWidget, HomeTrainingTeaser, and HomeVereinsmeisterschaftenTeaser.
- Created XML layout for tablet app window dump.
- Implemented API endpoints for fetching and updating homepage settings.
- Added API for retrieving spielplan options, including team extraction logic.
2026-05-29 15:37:45 +02:00
Torsten Schulz (local)
1ea9596006 Refactor code structure for improved readability and maintainability 2026-05-29 10:55:59 +02:00
Torsten Schulz (local)
cdbe71eaec Refactor code structure for improved readability and maintainability 2026-05-29 08:52:03 +02:00
Torsten Schulz (local)
125a00819d Refactor code structure for improved readability and maintainability 2026-05-29 00:13:12 +02:00
Torsten Schulz (local)
b4c31374c0 feat(android): sort CMS users, remove invite button, close CMS submenu on navigate 2026-05-29 00:11:42 +02:00
Torsten Schulz (local)
c8b7f5ec2e test: fix ViewModel unit tests (Cms/Gallery) and enable ByteBuddy experimental flag 2026-05-28 09:42:01 +02:00
Torsten Schulz (local)
0528334eb4 feat: replace success modal with non-blocking toast notification
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m14s
feat: add global event listener for mannschaften updates in Navigation component

feat: notify app of mannschaften changes after CSV save and handle visibility changes

refactor: remove unused anlagen page

fix: update CmsMannschaften reference in sportbetrieb page for reactivity

fix: enhance authentication token retrieval in passkey API endpoints

feat: implement refresh session and access token generation for Android clients in passkey login

fix: unify token retrieval method across passkey API endpoints

feat: add MediaTypes utility for JSON content type in Android app

feat: create PasskeyRepository for handling passkey authentication and registration in Android app

feat: add validated text field and rich text components for Android UI

feat: implement newsletter subscription and unsubscription screens in Android app

feat: create public pages including Impressum with dynamic content loading
2026-05-28 08:33:28 +02:00
Torsten Schulz (local)
e033d716dd feat: Add CMS and Member Area screens with ViewModels
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m23s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m18s
- Implemented CmsViewModel to manage CMS data loading and state.
- Created MemberAreaDetailScreens for displaying member information and news.
- Added MembersViewModel and MemberNewsViewModel for managing member data and news.
- Developed MemberAreaScreen to provide navigation and display member-related options.
- Introduced ProfileScreen and ProfileViewModel for user profile management.
- Implemented state management for loading, error handling, and form updates across screens.
2026-05-28 08:01:35 +02:00
Torsten Schulz (local)
e195d5d189 chore(version): update version to 1.6.1
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m6s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m13s
2026-05-27 20:45:37 +02:00
Torsten Schulz (local)
533e89c405 chore(ci): trigger analyze job after lint fixes
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m12s
2026-05-27 20:34:27 +02:00
Torsten Schulz (local)
083244bc83 chore(lint): run eslint --fix; add safe global fallbacks and fix empty catches
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-05-27 20:33:08 +02:00
Torsten Schulz (local)
9def0fdc32 chore(lint): add safe fallbacks for Nitro globals (getMethod/getRequestURL) in passkey and middleware handlers
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 20:02:46 +02:00
Torsten Schulz (local)
512756cb48 chore(lint): manual fixes - remove redundant global declarations; add safe getMethod fallback; remove unused catch vars
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-05-27 20:00:48 +02:00
Torsten Schulz (local)
19d2f21fc3 refactor(security): use centralized getServerDataPath in password-reset-log.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-05-27 19:55:11 +02:00
Torsten Schulz (local)
5074e8f8f8 fix(security): centralize data path validation in getServerDataPath; enforce segment whitelist and resolved-path check
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-05-27 19:53:59 +02:00
Torsten Schulz (local)
18a08b0e7a fix(security): validate resolved data path to prevent path traversal
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-05-27 19:51:15 +02:00
Torsten Schulz (local)
026e4ba3e4 fix(password-reset-log): improve filename sanitization and error handling in getDataPath
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m3s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 19:41:12 +02:00
Torsten Schulz (local)
58fd7fa5c6 feat(auth): implement Android refresh token handling and session management
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints.
- Implemented new tests for login, logout, and refresh functionalities specific to Android clients.
- Enhanced password reset logging with normalization and masking of email addresses.
- Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs.
- Introduced a new utility for managing password reset logs with retention policies.
- Added tests for password reset log utilities to ensure proper functionality and privacy compliance.
- Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
2026-05-27 19:34:53 +02:00
755442fb70 Merge branch 'main' into dev
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-05-27 19:05:18 +02:00
d31515bc79 Merge pull request 'dev' (#33) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 4m37s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #33
2026-05-27 19:04:15 +02:00
Torsten Schulz (local)
1710c9349d feat(auth): implement token rotation and session management for persistent Android login
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m22s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m12s
2026-05-27 18:55:22 +02:00
Torsten Schulz (local)
cd025b1f92 fix(package): update qs to version 6.15.2
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m15s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 4m49s
2026-05-27 18:23:16 +02:00
Torsten Schulz (local)
2c681cf65c Android-Cleanup und build fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 18:12:10 +02:00
Torsten Schulz (local)
92099685e6 Saison-Auswahl hinzugefügt
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m17s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 18:01:32 +02:00
Torsten Schulz (local)
7e0c92368e Android-Umsetzung der Homepage
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m22s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 17:54:24 +02:00
Torsten Schulz (local)
817f5e02ca feat(gradle): add Kotlin and new DSL configuration options
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 3m13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 17:48:38 +02:00
Torsten Schulz (local)
d3be0a269f feat(theme): add color and typography definitions for the app
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m52s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-26 16:43:17 +02:00
Torsten Schulz (local)
8e318b0b52 feat(android): initial project setup with Gradle, AndroidManifest, and MainActivity
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 3m13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-26 16:39:10 +02:00
b729e90e38 Merge pull request 'dev' (#32) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m53s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m7s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #32
2026-05-22 14:05:21 +02:00
Torsten Schulz (local)
acfcf773f7 fix(version): update version to 1.5.2 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m6s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m13s
2026-05-22 13:54:22 +02:00
Torsten Schulz (local)
1ea7f7a63f refactor(vereinsmeisterschaften): streamline file existence check and improve error handling
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-05-22 13:54:02 +02:00
Torsten Schulz (local)
7289adb7a0 test: avoid gitleaks false positive in profile spec
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m51s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m6s
2026-05-21 08:18:30 +02:00
Torsten Schulz (local)
19d7aeefb0 test: expand endpoint coverage and harden deploy gate
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 15s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-21 08:03:59 +02:00
92c0610dcb Merge pull request 'dev' (#31) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m1s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #31
2026-05-21 00:09:31 +02:00
Torsten Schulz (local)
5fce08ab75 fix(version): update version to 1.5.1 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-21 00:08:04 +02:00
Torsten Schulz (local)
1c9dff0932 feat(mannschaften): show rise/fall arrow in table rank column
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-05-21 00:07:18 +02:00
Torsten Schulz (local)
fd83b18642 fix(import): prefer seasonal mannschaften csv for tables
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-05-20 18:58:46 +02:00
6db05c1dc6 Merge pull request 'dev' (#30) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #30
2026-05-20 18:42:31 +02:00
Torsten Schulz (local)
84536956c4 fix(version): update version to 1.5.0 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-20 18:42:04 +02:00
Torsten Schulz (local)
459da00820 fix(import): publish season spielplan json after import
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
2026-05-20 18:12:32 +02:00
Torsten Schulz (local)
f883d45452 fix(security): harden season file paths for semgrep
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
2026-05-20 18:03:29 +02:00
Torsten Schulz (local)
11ff823fe2 feat(mannschaften): align public pages with season query logic
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-05-20 18:01:07 +02:00
Torsten Schulz (local)
cfd9365d07 feat(mannschaften): update team information display and add data update timestamps
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:57:02 +02:00
Torsten Schulz (local)
f2f76dec56 feat(cms): add season dropdown/create and restore baelle ratio
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:49:19 +02:00
Torsten Schulz (local)
2d42ef3ecd feat(mannschaften): split SUN columns and prepare seasonal team CSVs
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m40s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:45:14 +02:00
Torsten Schulz (local)
e19158558d feat(mannschaften): add matches/table tabs on team detail pages
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:41:43 +02:00
Torsten Schulz (local)
bf4db389ff feat(api): add endpoint for team table data by season
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:38:17 +02:00
Torsten Schulz (local)
964a68cdfd refactor(import): drop meetings excerpt from tables export
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-05-20 17:36:10 +02:00
Torsten Schulz (local)
21b39d4e5c feat(import): add daily click-tt league table import by season
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:28:07 +02:00
68b6ab593a Merge pull request 'fix(ci): avoid shell-expansion in node version-compare helper' (#29) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m46s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #29
2026-05-20 17:08:28 +02:00
Torsten Schulz (local)
3658589d94 fix(ci): avoid shell-expansion in node version-compare helper
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-20 17:03:04 +02:00
4e42ddfee4 Merge pull request 'ci: ensure version-check scripts are executable; invoke via bash' (#28) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #28
2026-05-20 17:00:19 +02:00
Torsten Schulz (local)
a80ea7b892 ci: ensure version-check scripts are executable; invoke via bash
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
2026-05-20 16:54:45 +02:00
6a6b8e0a1b Merge pull request 'ci: disable PR version-gate; use prod-version check on main' (#27) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 16s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #27
2026-05-20 16:11:57 +02:00
Torsten Schulz (local)
f7c6caebc1 ci: disable PR version-gate; use prod-version check on main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m42s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-20 16:08:09 +02:00
ddb170cffc Merge pull request 'chore: bump version to 1.4.7' (#26) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #26
2026-05-20 15:46:46 +02:00
Torsten Schulz (local)
79e2ae9b87 chore: bump version to 1.4.7
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 15:39:19 +02:00
66f2718714 Merge pull request 'ci: run analyze only on push; remove PR-specific checks' (#25) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #25
2026-05-20 13:16:41 +02:00
Torsten Schulz (local)
4891e965bb ci: run analyze only on push; remove PR-specific checks
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
Require Package Version Change / check (pull_request) Failing after 9s
2026-05-20 13:11:01 +02:00
f2f5ddb8ce Merge pull request 'dev' (#24) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #24
2026-05-20 13:10:27 +02:00
Torsten Schulz (local)
2fab6be58a ci: run version-change check only for PRs and main pushes (allow dev pushes)
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 12s
Require Package Version Change / check (pull_request) Failing after 9s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:56:40 +02:00
Torsten Schulz (local)
549f4a1510 ci: add production version check for PRs
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 12:53:46 +02:00
4d13ea5de6 Merge pull request 'dev' (#23) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #23
2026-05-20 12:45:58 +02:00
Torsten Schulz (local)
02ee4af49d chore: remove unused createRequire import from generate-pdf.post.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 13s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:27:48 +02:00
Torsten Schulz (local)
cc253c24db ci: remove version-gate workflow; bump version to 1.4.6
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-05-20 12:26:47 +02:00
45d343b1c1 Merge pull request 'dev' (#22) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #22
2026-05-20 12:24:53 +02:00
Torsten Schulz (local)
2eeed60387 ci: trigger analyze job test (noop)
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Code Analysis and Production Deploy / analyze (pull_request) Failing after 12s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:23:45 +02:00
Torsten Schulz (local)
63a1034e3d ci: include version-gate check in analyze job to block deploys on version mismatch
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 12:20:51 +02:00
7b9bdb9a36 Merge pull request 'dev' (#21) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m43s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #21
2026-05-20 12:09:48 +02:00
Torsten Schulz (local)
1561e1b861 chore: regenerate package-lock (remove bundled npm)
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m3s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m44s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:06:20 +02:00
Torsten Schulz (local)
3f7149d622 chore: bump version to 1.4.5
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m38s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 11:54:48 +02:00
Torsten Schulz (local)
bc9cc265e1 ci: require analysis before deploy; make deploy jobs depend on analyze
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m36s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 11:50:47 +02:00
a48c65bfb8 Merge pull request 'dev' (#20) 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 2m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #20
2026-05-20 11:46:54 +02:00
Torsten Schulz (local)
fdf72bdb96 chore: ensure npm 10.9.7 in engines, add build script, remove npm dep
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m40s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:38:30 +02:00
Torsten Schulz (local)
dbcc55d7fc Aktualisiere die Version auf 1.4.4 und stelle sicher, dass npm auf die Version 10.9.7 gesetzt ist
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 34s
2026-05-20 11:34:48 +02:00
Torsten Schulz (local)
f8a0370910 chore: regenerate package-lock using npm@10.9.7 for CI compatibility
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 36s
2026-05-20 11:33:37 +02:00
9a59fa5525 Merge pull request 'chore: bump version to 1.4.3; add logging and season-slug validation' (#19) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Reviewed-on: #19
2026-05-20 11:31:42 +02:00
Torsten Schulz (local)
41374da6ea chore: bump version to 1.4.3; add logging and season-slug validation
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 7s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 16s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:31:03 +02:00
70acfe6d5e Merge pull request 'Aktualisiere die Version auf 1.4.3, füge Validierung für Saison-Slugs hinzu und implementiere ein Logging-System für Fehler und Informationen' (#18) 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 1m51s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #18
2026-05-20 11:23:50 +02:00
Torsten Schulz (local)
e5c247f703 Aktualisiere die Version auf 1.4.3, füge Validierung für Saison-Slugs hinzu und implementiere ein Logging-System für Fehler und Informationen
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m49s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m37s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:20:54 +02:00
01f7d075e9 Merge pull request 'Aktualisiere die Version auf 1.4.2 und füge Funktionen zur Fehlerprotokollierung und Validierung von Saison-Slugs hinzu' (#17) 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 1m52s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #17
2026-05-20 11:09:48 +02:00
Torsten Schulz (local)
2503eb92af Aktualisiere die Version auf 1.4.2 und füge Funktionen zur Fehlerprotokollierung und Validierung von Saison-Slugs hinzu
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m51s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m43s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:07:56 +02:00
bb2785ba56 Merge pull request 'Refactor code structure for improved readability and maintainability' (#16) 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 1m54s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #16
2026-05-20 10:59:14 +02:00
Torsten Schulz (local)
4e918870f5 Refactor code structure for improved readability and maintainability
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m51s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 10s
2026-05-20 10:52:28 +02:00
e871790ee8 Merge pull request 'Add script for importing match schedule and logging' (#15) 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 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #15
2026-05-20 10:34:07 +02:00
Torsten Schulz (local)
0849c625cb Add script for importing match schedule and logging
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 33s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 10s
- Created `import-spielplan.js` to fetch and parse the match schedule from the specified URL, saving the output as JSON.
- Added `run-spielplan-import.sh` to automate the execution of the import script and log output.
- Introduced `spielplan.html` file to store the downloaded HTML content for further processing.
2026-05-19 16:23:28 +02:00
b19a9f6b8e Merge pull request 'dev' (#14) 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 2m2s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #14
2026-05-15 14:30:32 +02:00
Torsten Schulz (local)
c78adc0d52 Aktualisiere die Version auf 1.3.0 in der package.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m55s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 3m8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
2026-05-15 14:29:52 +02:00
Torsten Schulz (local)
806a4be2f9 Aktualisiere Hinweise zur Verwendung von Authenticatoren in Firefox und passe die bevorzugte Authenticator-Typen für die Passkey-Registrierung an.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m55s
2026-05-15 14:24:40 +02:00
Torsten Schulz (local)
5f96f719c1 Füge Hinweise zur Verwendung externer Authenticatoren in Firefox hinzu und passe die Registrierung von Passkeys an.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-15 14:13:22 +02:00
Torsten Schulz (local)
28b1001826 Füge Hinweise zur Verwendung von Passkeys hinzu und aktualisiere die Beschreibung der Passkey-Anmeldung im Profil.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
2026-05-15 14:05:18 +02:00
Torsten Schulz (local)
dcc4055eca Füge Unterstützung für Platform-Authentifikatoren hinzu und passe die Passkey-Registrierungsoptionen für Firefox an.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m56s
2026-05-15 14:01:18 +02:00
Torsten Schulz (local)
4521ce002e Füge Unterstützung für Firefox-Browser hinzu, indem die Registrierung von Passkeys ohne bevorzugten Authentifikator-Typ ermöglicht wird.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m52s
2026-05-15 13:56:12 +02:00
Torsten Schulz (local)
842a516ce6 Vereinfachung der Passkey-Registrierung durch Entfernen der bevorzugten Authentifikator-Typen und Anpassung der Fehlerbehandlung.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m52s
2026-05-15 13:36:53 +02:00
Torsten Schulz (local)
48f8b46e57 Erweitere die Passkey-Registrierung um Unterstützung für bevorzugte Authentifikatortypen und verbessere die Fehlerbehandlung.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-15 13:30:15 +02:00
Torsten Schulz (local)
8ae7dcdbf1 Implement passkey login functionality and enhance passkey support checks
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m7s
2026-05-15 13:20:09 +02:00
667b65bf29 Merge pull request 'Enhance fillable template generation by adding logo and refactoring header drawing' (#13) 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 2m1s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #13
2026-05-12 23:30:08 +02:00
Torsten Schulz (local)
dba2747883 Enhance fillable template generation by adding logo and refactoring header drawing
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m59s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 11s
- Integrated a logo into the fillable PDF templates for improved branding.
- Refactored the header drawing logic in create-fillable-template.js to streamline the process and ensure consistency across multiple pages.
- Updated the membership and membership fillable PDF templates to reflect these changes.
2026-05-12 23:25:28 +02:00
21752fedcc Merge pull request 'Update package version to 1.2.0, enhance fillable template generation with underlined text fields, and update PDF templates' (#12) 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 2m8s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #12
2026-05-12 23:05:16 +02:00
Torsten Schulz (local)
a30692a053 Update package version to 1.2.0, enhance fillable template generation with underlined text fields, and update PDF templates
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m19s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 3m25s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 11s
- Bumped the package version to 1.2.0 in package.json.
- Refactored the fillable template generation script to use a new function for creating underlined text fields, improving code readability and maintainability.
- Updated the membership and membership fillable PDF templates to reflect changes in the form fields.
2026-05-12 23:00:07 +02:00
742687f82b Merge pull request 'dev' (#11) 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 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #11
2026-05-06 16:06:18 +02:00
Torsten Schulz (local)
fddde56076 Update package version to 1.1.6 and enhance deploy script with dependency checks
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m29s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m7s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 11s
- Bumped the package version to 1.1.6 in package.json.
- Added `install_dependencies_if_needed` function in deploy-test.sh to conditionally install dependencies based on the state of package-lock.json.
- Improved logging of build output to a file for better error tracking during deployment.
- Updated Nuxt cache handling to retain .nuxt directory for faster builds unless explicitly cleaned.
2026-05-06 16:03:16 +02:00
Torsten Schulz (local)
c385df4a0c Add dependency installation check and logging to deploy script
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m54s
- Introduced `install_dependencies_if_needed` function to conditionally install dependencies based on the presence and changes in `package-lock.json`.
- Updated the deployment process to log build output to a file for better error tracking.
- Modified Nuxt configuration to disable source maps in production and prevent reporting of compressed sizes in Vite builds.
2026-05-06 15:58:02 +02:00
7152b54b68 Merge pull request 'Update candidate paths for CSV file retrieval in mannschaften.get.js' (#10) 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 2m14s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #10
2026-05-05 15:20:52 +02:00
Torsten Schulz (local)
e44d3c5c74 Update package version to 1.1.5 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m5s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m35s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Successful in 11s
2026-05-05 15:14:38 +02:00
Torsten Schulz (local)
c409fa6d4b Update candidate paths for CSV file retrieval in mannschaften.get.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
- Adjusted the logic to prioritize the new CMS write target for public data.
- Updated comments to clarify the order of candidate paths for file retrieval.
2026-05-05 15:13:22 +02:00
21b4e8bc9f Merge pull request 'dev' (#9) 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 1m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #9
2026-04-27 16:52:43 +02:00
Torsten Schulz (local)
0fa19493c5 Refactor readPackageVersion function to support multiple candidate paths for package.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
- Updated the logic to read the package version from either the current directory or the parent directory.
- Added error handling to continue searching through candidate paths if the first read fails.
2026-04-27 16:52:12 +02:00
Torsten Schulz (local)
c145a723ed Update package version to 1.1.4 in package.json and package-lock.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
2026-04-27 16:47:57 +02:00
91fb3d79c5 Merge pull request 'dev' (#8) 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 2m8s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #8
2026-04-27 16:46:55 +02:00
Torsten Schulz (local)
d0b15f3e83 Update package version to 1.1.3 and postcss dependency to 8.5.12 in package.json and package-lock.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m4s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m25s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
2026-04-27 16:40:33 +02:00
Torsten Schulz (local)
e60c0f4481 Add logic to include active trainers as newsletter recipients
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 3m16s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Successful in 11s
- Enhanced the getRecipientsByGroup function to filter and add active trainers from users.json to the newsletter recipients list.
- Ensured that duplicate emails are not added to the recipients array.
2026-04-27 15:10:57 +02:00
Torsten Schulz (local)
27a096546f Implement user sorting feature in Benutzer.vue
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m19s
- Added a dropdown for sorting active users by first name or last name.
- Updated the display of active users to reflect the selected sorting order.
- Introduced helper functions to split names and format display names accordingly.
2026-04-27 15:04:41 +02:00
aee8705fa3 Merge pull request 'dev' (#7) 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 1m56s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #7
2026-04-16 14:06:43 +02:00
Torsten Schulz (local)
20a1cdd7f2 Update package version to 1.1.2 in package.json and modify code-analysis.yml to trigger analysis only on pull requests.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m49s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 7s
2026-04-16 13:53:31 +02:00
Torsten Schulz (local)
e3825ad217 Update package version to 1.1.1 in package.json for the Harheimer Tischtennis Club website.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-04-16 13:45:02 +02:00
99c03dccf2 Merge pull request 'Remove package version change requirement for main PRs in code-analysis.yml to streamline workflow.' (#6) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m55s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #6
2026-04-16 13:41:06 +02:00
Torsten Schulz (local)
a12f1f7815 Remove package version change requirement for main PRs in code-analysis.yml to streamline workflow.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m54s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m49s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-04-16 13:36:45 +02:00
d450175871 Merge pull request 'dev' (#5) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #5
2026-04-16 13:23:53 +02:00
Torsten Schulz (local)
6fea2749e0 Add app version display in Footer and implement version API endpoint
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m54s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 11s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
- Updated Footer.vue to show the application version for logged-in users.
- Added a new API endpoint to return the application version from package.json.
- Enhanced code-analysis.yml to require package version changes for main PRs.
2026-04-16 13:16:53 +02:00
Torsten Schulz (local)
18da725567 Refactor deployment scripts to use git fetch and reset for pulling latest changes. Update deploy-production.sh and deploy-test.sh to ensure a clean state before deployment. Modify code-analysis.yml to reflect these changes in deployment commands.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m58s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-04-16 13:11:23 +02:00
Torsten Schulz (local)
4d5fb43ebc Enhance deploy-test.sh with functions for Node.js version management, dependency installation, and public document synchronization. Implement checks for Node.js version requirements and improve error handling for document syncing. Update environment configuration in harheimertc.test.config.cjs to support development and test environments. Modify email recipient logic in contact and email service APIs to prevent notifications in test environments. Add tests to verify behavior in test conditions.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m52s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 12s
2026-04-16 13:06:14 +02:00
Torsten Schulz (local)
986b2056cd Enhance deploy-production.sh with new functions for Node.js version management and public document synchronization. Added checks for Node.js version requirements and improved error handling for document syncing. Updated package.json and package-lock.json to specify Node.js and npm engine requirements.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m57s
2026-04-15 21:51:08 +02:00
Torsten Schulz (local)
337c172d07 Refactor dependency installation in deploy-production.sh to use a dedicated function. This improves error handling for missing package-lock.json and ensures consistent installation behavior. Removed obsolete public-data restoration logic for cleaner script execution.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m2s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m0s
2026-04-15 21:43:32 +02:00
Torsten Schulz (local)
15b8f3c4c1 Update version in package.json from 1.0.0 to 1.1.0 for the Harheimer Tischtennis Club website.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 3m49s
Code Analysis and Production Deploy / deploy-production (push) Failing after 0s
2026-04-15 21:36:37 +02:00
Torsten Schulz (local)
510cfd39f9 Update code-analysis workflow to include production deployment steps and rename workflow for clarity. Add SSH setup and connection testing for secure deployment to production environment.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Failing after 1s
2026-04-15 21:30:09 +02:00
Torsten Schulz (local)
e0bad51764 Update commander dependency to version 13.1.0 in package.json and package-lock.json for improved functionality and compatibility.
All checks were successful
Code Analysis (JS/Vue) / analyze (push) Successful in 3m10s
2026-04-15 21:18:26 +02:00
Torsten Schulz (local)
c1de0c1671 Enhance deploy-production.sh with error handling for git pull failures. Provide user guidance for SSH key setup and switching to HTTPS if necessary. Update code-analysis.yml to include Node.js setup with caching for improved workflow efficiency.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m25s
2026-04-15 21:09:04 +02:00
Torsten Schulz (local)
2bedbee08d Upgrade nodemailer to latest major for audit compliance.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 10s
This removes the remaining SMTP command injection advisories by moving to nodemailer 8.0.5 and refreshing the lockfile accordingly.

Made-with: Cursor
2026-04-15 21:00:43 +02:00
Torsten Schulz (local)
9c54b6907e Apply non-major audit updates and harden path handling for Semgrep.
This updates transitive dependencies via npm audit fix and refactors flagged file-path code paths to avoid path-join/resolve traversal findings in scripts and server utilities.

Made-with: Cursor
2026-04-15 21:00:28 +02:00
Torsten Schulz (local)
edfab28fd3 Add security comments to path handling in various scripts to clarify internal constant usage and mitigate path traversal risks. Update logging in registration and verification processes for improved clarity.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 2m48s
2026-04-15 20:52:38 +02:00
Torsten Schulz (local)
5f79d220cf Refactor PDF generation process in membership API to ensure consistent directory creation for uploads. Update final PDF path handling to improve clarity and maintainability of the code.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-04-15 20:50:29 +02:00
Torsten Schulz (local)
0a82b33afc Refactor PDF generation logic in membership API to improve error handling and enhance font embedding. Update LaTeX template for German language support and streamline debugging messages. Ensure encrypted data handling is consistent and improve command execution error management for PDF generation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 3m6s
2026-04-15 20:46:18 +02:00
Torsten Schulz (local)
1922e85184 Add mock implementations for role checks in auth utility and enhance sharp mock with image metadata retrieval
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-04-15 20:44:54 +02:00
Torsten Schulz (local)
0fb8052a77 Enhance deploy-test.sh script with error handling for root execution and write permissions. Add checks for successful git pull and provide user guidance for SSH key setup and repository access issues.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-04-15 20:39:59 +02:00
Torsten Schulz (local)
ef2d9353f5 Enhance ESLint configuration to include support for .mjs and .cjs file types. Update ignored files patterns to ensure proper linting of project files. Refactor Vue component templates for improved readability and maintainability, including consistent formatting and structure across various components. Update error handling in save functions to prevent silent failures.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-04-15 20:37:14 +02:00
Torsten Schulz (local)
1aae808e5f Update beitrittserklärung template PDF to the latest version.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m4s
2026-04-15 20:28:36 +02:00
Torsten Schulz (local)
75e6d66d25 Update German text for clarity in member management UI and API error messages, correcting "Fuer" to "Für" and "koennen" to "können" for improved readability.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 55s
2026-04-01 11:55:27 +02:00
Torsten Schulz (local)
daabeec33c Implement birthdate input in member profile management. Update API to handle birthdate data for user profiles and enhance visibility settings for birthday display in member lists.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m4s
2026-04-01 11:19:23 +02:00
Torsten Schulz (local)
0fb58af194 Add birthdate handling in member registration and management. Update UI to conditionally require birthdate for new members, and enhance API to enforce birthdate validation. Improve tests to cover new birthdate requirements.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-31 07:25:44 +02:00
Torsten Schulz (local)
8ffd267dfc Enhance member management UI by adding hall key status display and editing capabilities. Update API to support hall key data integration in member records.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
2026-03-30 15:41:16 +02:00
Torsten Schulz (local)
5eee7df7e4 Refactor authentication logic in members API to use getUserFromToken for user retrieval. Update error messages for better clarity and enhance tests to reflect changes in authentication handling.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m2s
2026-03-30 15:29:25 +02:00
Torsten Schulz (local)
7dea265eef Add filter option for members with hall key in member management UI. Update member listing logic to reflect filtered results based on hall key status.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-29 15:25:27 +02:00
Torsten Schulz (local)
381ec55fd1 Refactor member acceptance logic in API to improve handling of legacy records. Remove debug logging and clarify acceptance criteria for manual members.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-29 15:00:53 +02:00
Torsten Schulz (local)
c30911daed Refactor saveMember function to enhance duplicate member handling, allowing updates to existing duplicates and improving error handling for member existence checks.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-03-29 14:54:18 +02:00
Torsten Schulz (local)
bdc9eef707 Refactor saveMember function to improve duplicate member handling and streamline member updates. Enhance error messaging for non-existent members and ensure new members are added with default active status.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-29 14:49:04 +02:00
Torsten Schulz (local)
f7701d698f Add hall key feature to member management, including UI updates for displaying and editing hall key status. Update API to handle hall key data in member records.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m6s
2026-03-29 14:37:49 +02:00
Torsten Schulz (local)
49e7255062 Enhance CSV saving functionality by adding token retrieval from authorization header if not present in cookies. Update tests to validate CSV saving for users with 'vorstand' role.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-18 13:12:32 +01:00
Torsten Schulz (local)
74246e6b08 Implement status toggle functionality for contact requests, updating the status display and adding error handling. Enhance the UI with a new button for marking requests as completed or reopening them.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-11 21:16:03 +01:00
Torsten Schulz (local)
6230c96bc9 Refactor links section to use dynamic rendering with computed properties, enhancing maintainability and scalability. Add new 'Links' tab in CMS for better navigation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-04 16:05:34 +01:00
Torsten Schulz (local)
3fb40bd87d Erweitere die Navigation um einen neuen Link zu "Links" und aktualisiere die Logik zur Bestimmung des aktuellen Submenüs, um die neue Route zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-04 14:53:11 +01:00
Torsten Schulz (local)
46c2c14ae8 Füge Unterstützung für Kontaktanfragen hinzu, einschließlich neuer Routen und Berechtigungen für Trainer und Vorstand. Aktualisiere E-Mail-Versandlogik, um Anfragen an alle relevanten Empfänger weiterzuleiten.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-26 14:28:54 +01:00
Torsten Schulz (local)
ff8c1970df Ersetze Willkommensnachricht durch Geburtstags-Widget mit dynamischer Anzeige der nächsten Geburtstage
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 16:39:52 +01:00
Torsten Schulz (local)
8347a86727 Entferne die JSON-Darstellung des Mitglieds aus der Mitgliederansicht
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-02-14 16:26:58 +01:00
Torsten Schulz (local)
9a6d32dcb3 Füge ESM-Importe und Skriptbeschreibung für das Aufteilen von Namen in Benutzer- und Bewerbungsdateien hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 16:25:29 +01:00
Torsten Schulz (local)
161618f6fb Füge Skripte zum Aufteilen von Namen in firstName und lastName für Mitglieder und Bewerbungen hinzu, einschließlich Backup-Funktionalität.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 15:58:11 +01:00
Torsten Schulz (local)
0b3fba44a4 Füge Skript zum Aufteilen von Namen in firstName und lastName für Benutzer hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 15:50:37 +01:00
Torsten Schulz (local)
d35e1c9a3e Füge Vorname und Nachname in das Registrierungsformular und die Mitgliederverwaltung ein
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 15:48:56 +01:00
Torsten Schulz (local)
528353132a Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:45:51 +01:00
Torsten Schulz (local)
cd5e5cd781 Füge die Anzeige der Mitgliederdaten im JSON-Format in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:42:58 +01:00
Torsten Schulz (local)
ebbffcc5c4 Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:38:39 +01:00
Torsten Schulz (local)
5c760d7fa8 Füge Sichtbarkeits-Flags für E-Mail und Telefon in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 03:31:28 +01:00
Torsten Schulz (local)
d40073ac7b Füge Sichtbarkeits-Flags für E-Mail, Telefon, Adresse und Geburtstag in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:23:59 +01:00
Torsten Schulz (local)
b25cf13d3c Füge Sichtbarkeits-Flags für Mitglieder hinzu, um die Anzeige von E-Mail, Telefon, Adresse und Geburtstag zu steuern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:16:28 +01:00
Torsten Schulz (local)
3287102761 Füge Vorname und Nachname zu den Mitgliederdaten hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 03:08:19 +01:00
Torsten Schulz (local)
08624cabbe Verbessere die Sichtbarkeit von Mitgliederdaten, indem das Geburtsdatum im Edit-Formular hinzugefügt wird
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:05:15 +01:00
Torsten Schulz (local)
d37f182928 Füge Skript hinzu, um Sichtbarkeitsflags für Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-14 02:58:30 +01:00
Torsten Schulz (local)
79c45be7c7 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:52:44 +01:00
Torsten Schulz (local)
d52f3ffc8d Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:50:57 +01:00
Torsten Schulz (local)
64baaf8535 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:48:30 +01:00
Torsten Schulz (local)
e665495003 Verbessere die Sortierlogik in der Mitgliederliste für Namen, Nachnamen und Geburtstage mit robusteren Vergleichen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:42:43 +01:00
Torsten Schulz (local)
8f444c59eb Füge Sortieroptionen zur Mitgliederliste hinzu und verbessere die Sortierung nach Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:36:26 +01:00
Torsten Schulz (local)
8117335af9 Entferne die Sortieroptionen und passe die Mitgliederanzeige an, um die Sortierung direkt aus der Mitgliederliste zu entfernen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-02-14 02:36:19 +01:00
Torsten Schulz (local)
85ec99b08c Optimiere das Template der Mitgliederliste durch Entfernen von überflüssigem Code und verbessere die Sortieroptionen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
2026-02-14 02:28:40 +01:00
Torsten Schulz (local)
04571e6444 Verbessere die Struktur des Templates in der Mitgliederliste und füge Sortieroptionen hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:22:52 +01:00
Torsten Schulz (local)
5799f97570 Entferne überflüssige Zeile im Template der Mitgliederliste
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:17:27 +01:00
Torsten Schulz (local)
8ab08f4c09 Füge Header und Sortieroptionen zur Mitgliederliste hinzu
Some checks are pending
Code Analysis (JS/Vue) / analyze (push) Has started running
2026-02-14 02:16:36 +01:00
Torsten Schulz (local)
fcf3168692 Entferne überflüssige geschweifte Klammer in der formatDate-Funktion
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-14 02:13:25 +01:00
Torsten Schulz (local)
cfd209d7ee Filtere den Admin-Account aus der Mitgliederliste heraus, um die Sichtbarkeit zu verbessern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 02:07:45 +01:00
Torsten Schulz (local)
ee1709ffb2 Füge Sortieroptionen für Mitgliederliste hinzu und implementiere Sortierlogik nach Name, Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:06:36 +01:00
Torsten Schulz (local)
8bb02b6e4a Füge dotenv-Konfiguration zum Skript hinzu, um Umgebungsvariablen zu laden
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:00:39 +01:00
Torsten Schulz (local)
7a20af2772 Füge active-Feld zu Mitgliedsdaten hinzu und implementiere Skript zum Aktivieren aller Mitglieder
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 01:54:39 +01:00
Torsten Schulz (local)
3e610e68b6 Füge Debug-Logs hinzu, um alle geladenen Mitglieder (decryptet) anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 01:47:20 +01:00
Torsten Schulz (local)
485b21c13e Füge Diagnose-Skript hinzu, um Mitglieder aus members.json mit Status und Sichtbarkeit anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 01:43:18 +01:00
Torsten Schulz (local)
08b1edc354 Füge Skript zum Re-Encryptieren von Klartext-Mitgliedsanträgen hinzu; implementiere Backup-Funktion und Fehlerbehandlung
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 01:37:42 +01:00
Torsten Schulz (local)
6e297c682c Füge Geburtstags-Widget hinzu und implementiere Geburtstagsladefunktion; erweitere Sichtbarkeitseinstellungen für Geburtstage in Profil und API
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-13 17:27:27 +01:00
Torsten Schulz (local)
3d3e22bb1b Implementiere zentralen E-Mail-Service für Registrierungsbenachrichtigungen und entferne veralteten Code
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-11 15:41:03 +01:00
Torsten Schulz (local)
d18b671532 Ändere Sichtbarkeitseinstellungen für Mitglieder: Standardmäßig sichtbar für alle eingeloggten Mitglieder, es sei denn, sie sind explizit verborgen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:37:13 +01:00
Torsten Schulz (local)
af3c0164ef Füge Sichtbarkeitsoptionen für Mitglieder und registrierte Benutzer hinzu; aktualisiere die Sichtbarkeitseinstellungen basierend auf Benutzerpräferenzen in der Mitgliederabfrage und dem Sichtbarkeits-Skript.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-11 14:25:49 +01:00
Torsten Schulz (local)
c681194462 Make visibility opt-in by default; coerce visibility booleans; only 'vorstand' overrides
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:10:54 +01:00
Torsten Schulz (local)
141a15a6cb Respect per-user visibility; only 'vorstand' overrides visibility; UI shows contactHidden per-member
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:27:24 +01:00
Torsten (PC)
ce5915a3bc fixed .gitignore
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 13:08:07 +01:00
Torsten Schulz (local)
677140bd33 Füge Sichtbarkeitspräferenzen für Mitgliederprofile hinzu: Ermögliche Benutzern, ihre E-Mail, Telefonnummer und Adresse für andere eingeloggte Mitglieder sichtbar zu machen. Aktualisiere die API, um diese Einstellungen zu respektieren und bei der Profildatenrückgabe zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:04:45 +01:00
Torsten Schulz (local)
8a1e309eba Verbessere Mitgliederabfrage: Filtere manuelle Mitglieder nach aktiven/akzeptierten Status und entferne nicht benötigte Datenschutzlogik.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 44s
2026-02-11 12:49:14 +01:00
Torsten Schulz (local)
0d533710cd Refactor file handling to prioritize internal data directories for backups and uploads; enhance error handling and logging for metadata and CSV operations.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 11:42:24 +01:00
441 changed files with 37249 additions and 4337 deletions

0
.codex Normal file
View File

View File

@@ -1,16 +1,38 @@
name: Code Analysis (JS/Vue)
name: Code Analysis and Production Deploy
on:
pull_request:
push:
branches: [ main ]
branches: [ main, dev ]
jobs:
analyze:
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0
- name: Ensure clean workspace
run: |
git reset --hard HEAD
git clean -fdx
- name: Debug dependency files
run: |
echo "commit: $(git rev-parse HEAD)"
echo "branch ref: ${GITHUB_REF:-unknown}"
echo "package.json checksum:" && sha256sum package.json
echo "package-lock.json checksum:" && sha256sum package-lock.json
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Workspace sanity check
run: |
@@ -25,6 +47,15 @@ jobs:
node -v
npm -v
- name: Check package.json version vs production
if: "github.ref == 'refs/heads/main'"
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_PORT: ${{ vars.PROD_PORT }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
run: bash scripts/check-version-against-prod.sh
- name: gitleaks (Secrets Scanning)
run: |
# Try to get the latest release asset URL
@@ -58,7 +89,11 @@ jobs:
rm -f gitleaks.tar.gz
- name: Install dependencies
run: npm ci
run: |
if ! npm ci; then
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
npm install
fi
- name: Lint
run: npm run lint
@@ -82,3 +117,64 @@ jobs:
./osv-scanner --version
test -f ./package-lock.json
./osv-scanner --lockfile ./package-lock.json
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Prepare SSH
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"echo SSH OK"
- name: Run production deployment script
run: |
ssh -i ~/.ssh/id_ed25519 \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
deploy-test:
needs: analyze
runs-on: ubuntu-latest
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Prepare SSH
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"echo SSH OK"
- name: Run test deployment script
run: |
ssh -i ~/.ssh/id_ed25519 \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"

View File

@@ -0,0 +1,11 @@
name: Require Package Version Change (disabled)
on:
workflow_dispatch: {}
jobs:
noop:
runs-on: ubuntu-latest
steps:
- name: Disabled
run: echo "version-gate workflow disabled — gating is handled in code-analysis.yml"

28
.github/workflows/android-ci.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Android CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper/
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- name: Build
run: |
./gradlew :app:assembleDebug

13
.gitignore vendored
View File

@@ -88,6 +88,13 @@ out
.nuxt
dist
# Android / Gradle generated and machine-local files
/android-app/.gradle/
/android-app/.kotlin/
/android-app/**/build/
/android-app/local.properties
/android-app/gradle-local.properties
# Build output (but keep production data!)
.output
!.output/.gitkeep
@@ -143,6 +150,8 @@ Thumbs.db
# Temporary files
*.tmp
*.temp
temp/webpage-downloads/data/
temp/webpage-downloads/*.html
# Security tooling artifacts (CI downloads)
gitleaks
@@ -154,3 +163,7 @@ server/data/**
!server/data/.gitkeep
public/data/**
public/uploads/**
backups/*
public/data
server/data
public/uploads

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

36
ANDROID_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,36 @@
Android Architektur (Kotlin + Jetpack Compose) — Vorschlag
Packages/Module Struktur:
- app/ (Android-App module)
- src/main/java/de/harheimertc/
- ui/
- navigation/ (NavGraph, Routes)
- screens/ (HomeScreen, TermineScreen, SpielplanScreen, GalleryScreen, ContactScreen, AuthScreens, CMS Screens)
- components/ (TopBar, BottomNav, Cards, Modals)
- theme/ (Color.kt, Typography.kt, Theme.kt)
- data/
- api/ (Retrofit interfaces, DTOs)
- repository/ (Repositories für Domain-Modelle)
- local/ (Room DAOs, Entities)
- di/ (Hilt Modules)
- domain/ (UseCases, Business-Logic)
- util/ (Extensions, DateUtils, ImageUtils)
- auth/ (AuthManager, Passkeys helper)
Wichtige Dateien:
- `MainActivity.kt` — Hosts Compose NavHost
- `AppTheme.kt` — Compose Material3 Theme mit Token-Mapping
- `NetworkModule` (Hilt) — Retrofit + OkHttp + Auth Interceptor
- `Repository` Layer — entkoppelt UI von Netz
- `Room` Entities — für Caching von Termine/News/Galerie
Auth-Strategie:
- AuthRepository verwaltet Login/Logout, `checkAuth()` (mirroring `/api/auth/status`).
- Token/Cookie-Speicherung: `EncryptedSharedPreferences` für Tokens oder `CookieJar` mit OKHttp-Client.
- Passkeys: `Fido2Client` wrapper + Bridge zu Server-API (Formate prüfen).
Build / Module Tipps:
- Start mit Single Module `app/` und später evtl. `:data`, `:domain` Trennung.
- Verwende Gradle Kotlin DSL (build.gradle.kts).
Diese Architekturdatei wurde generiert; ich kann nun ein initiales Gradle-Kotlin-Scaffold erzeugen. Soll ich das direkt in `android-app/` ablegen? (Ja/Nein)

372
ANDROID_KOTLIN_PLAN.md Normal file
View File

@@ -0,0 +1,372 @@
Android App — Kotlin (Jetpack Compose) Plan und Abhakliste
Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web-UI 1:1 abbildet (Farben, Typografie, Funktionalitäten). Diese Datei enthält Architekturentscheidungen, empfohlene Bibliotheken und eine detaillierte Abhakliste (schrittweise).
1) Zusammenfassung der Entscheidungen
- Plattform: Native Android
- Sprache: Kotlin
- UI-Toolkit: Jetpack Compose (Compose Material3)
- Architektur: MVVM mit `ViewModel` + Kotlin Coroutines + Flow
- DI: Hilt
- HTTP-Client: Ktor Client oder Retrofit + OkHttp (empfohlen: Retrofit für breite Community-Docs)
- Bild-Loading: Coil
- Lokale DB / Caching: Room + DataStore (Preferences)
- Background/Sync: WorkManager
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
2) Design & Farben
- Material Theme (Material3) mit Farben aus `tailwind.config.js` (Primary + Accent).
- Fonts: Inter & Montserrat via Google Fonts (Download/Bundle oder Play-Services-Download at runtime).
- Mapping: Tailwind-Token → `colors.xml` / Compose `Color` tokens.
3) Empfohlene Abhängigkeiten (erste Implementierung)
- androidx.compose.* (ui, material3, navigation)
- androidx.lifecycle:lifecycle-viewmodel-ktx
- com.google.dagger:hilt-android
- retrofit2 + converter-moshi / kotlinx-serialization
- io.coil-kt:coil-compose
- androidx.room:room-runtime + room-ktx
- androidx.work:work-runtime-ktx
- androidx.datastore:datastore-preferences
- com.google.android.gms:play-services-auth (für passkeys falls nötig)
- io.sentry:sentry-android (optional)
4) Detaillierte Abhakliste (Schritte)
[x] 1. Repo-Analyse: Liste der externen Endpunkte und Auth-Anforderungen exportieren
[x] 2. Projekt-Scaffold: Android Studio Projekt mit Kotlin + Compose anlegen
[x] 3. App-Architektur: Module / Packages anlegen (ui, data, domain, di, util)
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
[x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
[x] 7d. Tablet-Navigation: öffentliche Haupt- und Subnavigation der Web-UI mit Portierungszielen abbilden
[x] 7e. Navigation: dynamische Mannschaftslinks, Galerie-Sichtbarkeit und rollenabhängiges `Intern` wie in der Web-UI anbinden
[x] 8. Start-Screen: `HomeScreen` webnah mit Hero, Navigation, Termine, Spielen, News und Aktionen umsetzen
[x] 9. Komponenten: NavBar, Footer, Cards, ImageGrid und News-Dialog implementieren
[x] 10. Öffentliche Screens aus der Web-Navigation portieren
- [x] `/` Startseite
- [x] `/termine`: öffentliche Terminliste mit Lade-, Leer- und Fehlerzustand
- [x] `/mannschaften/spielplaene`: Saison-, Wettbewerbs- und Mannschaftsfilter mit Spielkarten
- [x] `/verein/galerie`: Anzeige-Screen vorhanden
- [x] `/kontakt`: Formular-Screen vorhanden
- [x] `/mitgliedschaft`: Antrag, Validierung, PDF-Erzeugung und PDF-Öffnen
- [x] `/verein/ueber-uns`: CMS-Inhalt aus der öffentlichen Konfiguration
- [x] `/vorstand`: öffentliche Vorstandsangaben aus der Konfiguration
- [x] `/verein/geschichte`: CMS-Inhalt aus der öffentlichen Konfiguration
- [x] `/verein/satzung`: CMS-Inhalt und PDF-Aufruf aus der öffentlichen Konfiguration
- [x] `/vereinsmeisterschaften`: Ergebnisliste mit Jahresfilter und Statistik
- [x] `/links`: strukturierte CMS-Links mit Fallback-Verweisen
- [x] `/mannschaften`: Übersicht aus saisonaler Mannschafts-CSV
- [x] `/mannschaften/[slug]`: dynamische Mannschaftsdetails mit aktuellem Spielplan und Umschaltung `Matches`/`Tabelle`
- [x] `/spielsysteme`: Spielsystemkarten mit Kategoriefilter aus CSV
- [x] `/training`: Trainingsort und gruppierte Trainingszeiten aus der Konfiguration
- [x] `/training/trainer`
- [x] `/training/anfaenger`
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
[x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
- [x] `/impressum`
- [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
[x] 10b. Newsletter-Screens portieren
- [x] `/newsletter/subscribe`
- [x] `/newsletter/unsubscribe`
- [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
[x] 10c. Auth-Screens portieren
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
- [x] `/registrieren`
- [x] `/passwort-vergessen`
[x] 10d. Mitgliederbereich portieren
- [x] `/mitgliederbereich`: Übersicht
- [x] `/mitgliederbereich/mitglieder`
- [x] `/mitgliederbereich/news`
- [x] `/mitgliederbereich/profil`
- [x] `/mitgliederbereich/api`
[x] 10e. CMS-Screens nach Rollenberechtigung portieren
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
[x] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
- [x] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-/Newsletter-/Galerie-Endpunkte erledigt); Web bleibt bewusst Cookie-basiert
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
- [x] Login/Logout und verschlüsselte Token-Speicherung
- [x] Registrierung und Passwort-Reset
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
[x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
- [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
[x] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
- [x] Nativer Rich-Text-Editor für CMS-Inhalte ergänzt; Toolbar schreibt Quill-kompatible HTML-Fragmente und speichert denselben HTML-String wie die Web-UI
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
[x] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
- [x] Zentraler OkHttp-Cache für öffentliche GET-Antworten ergänzt; Offline-Fallback nutzt gecachte Antworten bis 7 Tage
- [x] Zentraler Coil-ImageLoader mit gemeinsamem OkHttp-Client, Memory-Cache und 75-MB-Diskcache ergänzt
- [x] Verschlüsselte persistente Offline-Daten für geschützte Mitglieder-/CMS-Inhalte mit `EncryptedSharedPreferences` implementiert
[x] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
- [x] App-Name und neue Galerie-Upload-Texte in deutsche und englische Ressourcen ausgelagert
- [x] i18n-Check durchgeführt; ältere Compose-Harttexte bleiben als separate, risikoarme Nachmigration offen
[x] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
- [x] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
- [ ] Compose-UI-Tests für kritische Screens ergänzen
- [x] Hilt androidTest dependencies und `kspAndroidTest` konfiguriert
- [x] `HiltTestApplication` in `androidTest`-Manifest gesetzt
- [x] `LoginScreenTest` zu `@HiltAndroidTest` migriert und `HiltAndroidRule` hinzugefügt
- [x] `TestHiltModules.kt` für androidTest hinzugefügt (TestBindings bereitgestellt)
[x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
- [x] Technische Release-Basis vorbereitet: `ANDROID_VERSION_CODE` und `ANDROID_VERSION_NAME` als Gradle-Properties eingeführt (`android-app/gradle.properties`) und im App-Gradle verdrahtet.
- [x] Production-Release-Flavor auf Produktiv-Backend parametrisierbar gemacht (`PRODUCTION_API_BASE_URL`, Default `https://harheimertc.de/`).
- [x] Release-Signing per sicheren Gradle-Properties vorbereitet (`RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`) statt Hardcoding.
- [x] `:app:assembleProductionRelease` erfolgreich gebaut (Stand 2026-05-29).
- [x] Play-Store-Listing-Basis ergänzt: Datenschutzseite unter `/datenschutz` sowie Skripte für Icon/Feature-Graphic-Export und Screenshot-Anonymisierung inklusive Anleitung (`android-app/PLAYSTORE_ASSETS.md`).
- [x] Konto-Lösch-URL für Play Store ergänzt: öffentliche Seite unter `/konto-loeschen` inklusive Prozessbeschreibung.
- [ ] Offen: Finales Upload-Keystore + Credentials in CI/Build-Host hinterlegen, Play-Store-Release-Notes und Store-Metadaten pflegen.
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
5) Kurzzeit-MVP (Priorität für erste Version)
- [x] A. Auth (Login/Logout)
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
- [x] C. Kontaktformular (absenden)
- [x] D. Bildanzeige + Caching
- [x] E. Theme & Fonts
6) Nächste Aktionen (sofort)
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
- Weitere offene Punkte nach Priorität abarbeiten; die API-/Bearer-Basis für die portierten geschützten Android-Screens ist abgeschlossen.
7) Umsetzungsprotokoll
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
- 2026-05-27: `Termine` und `Spielplan` als native Screens umgesetzt; Spielplan unterstützt Saison, Wettbewerb, Mannschaft, Ergebnis und zweizeilige Gruppeninformation.
- 2026-05-27: `Mitgliedschaft` mit Antrag/PDF-Abruf sowie Passwort-`Login`/`Logout` umgesetzt; offene Auth-Härtung separat ausgewiesen.
- 2026-05-27: Tokens verschlüsselt persistiert; Session-Wiederherstellung sowie Logout per Bearer-Token in den Auth-Endpunkten ermöglicht.
- 2026-05-27: Registrierung und Passwort-Reset an die vorhandenen Auth-Endpunkte angebunden.
- 2026-05-27: Product-Flavors `local`, `instantTest` und `production` eingerichtet; lokale Basis-URL ist per Gradle-Parameter überschreibbar.
- 2026-05-27: Gradle-Heap/Worker für Flavor-Builds festgelegt, nachdem paralleles D8/KSP mit dem 512-MiB-Standardheap nicht ausreichend Speicher hatte.
- 2026-05-27: Lokales Testsetup gegen Emulator geprüft; bei IPv6-gebundenem Nuxt-Dev-Server wird die von Nuxt ausgegebene Network-URL per `LOCAL_API_BASE_URL` verwendet.
- 2026-05-27: Adaptive Navigation umgesetzt; Tablet-Layouts ab `600dp` zeigen Header und Hauptmenue dauerhaft, Smartphone-Layouts behalten die vorhandene Navigation.
- 2026-05-27: Platzhalterlogo in der Android-Navigation durch das vorhandene Harheimer-TC-Weblogo als skalierte lokale PNG-Ressource ersetzt.
- 2026-05-27: Web-Navigation und `pages/` vollständig inventarisiert; Tablet-Haupt-/Subnavigation für die öffentlichen Bereiche strukturell angeglichen und alle fehlenden Screens einzeln in die Portierungsliste aufgenommen.
- 2026-05-27: Tablet-Header auf Web-Verhalten angepasst (Bereichswechsel öffnet Startseite und Submenü) und die native ActionBar zugunsten des App-Headers entfernt.
- 2026-05-27: Navigation mit Live-Mannschaftslinks, öffentlicher Galerie-Sichtbarkeit und rollenabhängigem `Intern` ergänzt; Mannschaftsübersicht/-detail sowie Training, Trainer und Anfänger nativ portiert.
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
- 2026-05-28: Rich-Text-Editor nativ umgesetzt: `/cms/inhalte` kann Über-uns, Geschichte, TT-Regeln und Satzung mit Android-Toolbar bearbeiten; gespeichert wird weiterhin HTML in `seiten.*` über `PUT /api/config`, kompatibel zur Web-/Quill-Ausgabe.
- 2026-05-28: Punkt 14 umgesetzt: Android-Galerie nutzt den strukturierten `/api/galerie/list`-Response, lädt Bilder über Coil aus `/api/media/galerie/:id`, und Admin/Vorstand kann Bilder nativ auswählen, lokal auf JPEG/2000px/85% komprimieren und per Multipart an `/api/galerie/upload` senden.
- 2026-05-28: Punkt 18 umgesetzt: `strings.xml` für Deutsch und Englisch ergänzt, App-Label und neue Galerie-Upload-UI auf Ressourcen umgestellt; i18n-Check weist die bestehenden älteren Compose-Harttexte als spätere Nachmigration aus.
- 2026-05-28: Punkt 11 abgeschlossen: Android sendet Bearer-Tokens zentral per OkHttp-Interceptor; die portierten geschützten Backend-Endpunkte akzeptieren Cookie- oder Bearer-Authentifizierung. Die Web-UI bleibt absichtlich bei HttpOnly-Cookie-Sessions und muss nicht auf Bearer umgestellt werden.
- 2026-05-28: Caching-Teil von Punkt 17 und MVP-D umgesetzt: OkHttp cached öffentliche GET-Antworten und nutzt gecachte Antworten offline, Coil nutzt denselben authentifizierten Client plus Memory-/Diskcache. Geschützte Daten werden bewusst nicht unverschlüsselt im HTTP-Diskcache persistiert.
- 2026-05-28: Testbasis für Punkt 20 begonnen: JVM-Unit-Tests für E-Mail- und ISO-Datum-Validierung ergänzt; `:app:testLocalDebugUnitTest` läuft mit `compileSdk 35` grün.
- 2026-05-28: Punkte 17, 19, 21 und 23 weiter umgesetzt: geschützte Mitglieder-/CMS-Daten werden verschlüsselt in Keystore-gestützten Preferences gecacht und bei Ladefehlern genutzt; Galerie-Accessibility und Thumbnail-Decoding verbessert; Sentry-Android 8.42.0 über optionalen `SENTRY_DSN`-Gradle-Parameter integriert.
- 2026-05-29: Play-Store-Listing-Vorbereitung ergänzt: eigenständige Web-Datenschutzseite (`/datenschutz`) sowie Asset-/Anonymisierungs-Skripte und Anleitung in `android-app/PLAYSTORE_ASSETS.md` hinzugefügt.
8) Android-Testumgebungen
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
- Lokal, wenn `10.0.2.2` nicht erreichbar ist: `./gradlew :app:installLocalDebug -PLOCAL_API_BASE_URL=http://<NUXT-NETWORK-HOST>:3100/`; die passende URL steht in der `npm run dev`-Ausgabe (hier `http://torstens:3100/`).
- Test-Instanz: `./gradlew :app:installInstantTestDebug` verwendet `https://harheimertc.tsschulz.de/` und die App-ID `de.harheimertc.test`.
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
8a) Aktueller Teststatus & Troubleshooting (Stand: 2026-05-28)
- **Status:** `:app:assembleAndroidTest` läuft durch; `:app:connectedAndroidTest` ist derzeit instabil und schlägt bei Instrumentation-Läufen fehl.
- **Beobachtete Probleme:**
- Kompilationsfehler in `LoginScreenTest.kt` wegen `HiltTestActivity` (Unresolved reference). Workaround: `createAndroidComposeRule<ComponentActivity>()` + `setContent{}` verwenden, damit `assembleAndroidTest` durchläuft.
- Laufzeit-/Device-Probleme bei `connectedAndroidTest`: `com.android.ddmlib.SyncException: Remote object doesn't exist!` und `DELETE_FAILED_INTERNAL_ERROR` beim Deinstallieren von Test-APKs.
- `AndroidTestLogcatPlugin` wirft `FileNotFoundException` für erwartete Log-/Crash-Dateien, weil Gradle/UTP manche Device-Artefakte nicht zuverlässig pulled.
- Einzelne Instrumentation-Tests (z. B. `CmsActivateResendTest`, `GalleryScreenTest`) zeigen Assertion-Fehlschläge — diese sollten isoliert reproduziert werden.
- **Kurzfristige Empfehlungen (nicht ausführen):**
- Emulator neu starten und sicherstellen, dass keine veralteten Test-APKs installiert sind.
- Manuell: `adb uninstall` der Test-Pakete, dann frisches `adb install -r` des Test-APKs und gezielter Einzeltest via:
`adb shell am instrument -w -e class <test-class>#<testMethod> de.harheimertc.test/androidx.test.runner.AndroidJUnitRunner`
parallel `adb logcat -v time > /tmp/harheimertc_live_logcat.txt` laufen lassen, um vollständige Logs zu speichern.
- Falls UTP/ddmlib `SyncException` weiter auftritt: Gradle-Parallelität reduzieren, Test-Plugins (z. B. `AndroidTestLogcatPlugin`) temporär deaktivieren oder Tests in kleinere Gruppen splitten.
- **Offene TestToDos:**
- Reproduzierbaren Einzeltest-Run mit vollständigem `logcat` erfassen (derzeit vom Nutzer pausiert).
- Flaky Tests isolieren und Hilt/KSP-Setup prüfen, damit `HiltTestActivity`-Importe nicht mehr fehlschlagen.
- Langfristig: Tests aufteilen, flaky tests markieren und CI-Job für androidTests gegen UTP-Transient-Fehler härten.
9) Dauerhaftes Android-Login: Architektur und Umsetzung
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
- Token-Modell:
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
- Backend-Arbeitspaket:
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
- Android-Arbeitspaket:
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
- Sicherheitsregeln:
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
---
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
**CMS-Verbesserungsplan (Analyse → Umsetzung)**
Ziel: Alle `cms/*`-Screens von rudimentärem Status zu vollständigen, getesteten Admin-Tools weiterentwickeln. Fokus: Datenintegrität, Berechtigungen, bessere UI/UX, Offline-Verhalten und Tests.
Kurzüberblick (3 Phasen):
- Phase A — Analyse (1-2 Tage): Inventar aller CMS-Endpunkte, fehlende CRUD-Workflows identifizieren, Prioritäten setzen (News, Benutzer, Kontaktanfragen, Newsletter, Config). Ergebnis: Aufgabenliste mit Aufwandsschätzung.
- Phase B — Implementierung MVP (1-2 Wochen): Kernfunktionen pro Bereich implementieren (News CRUD mit RichText-Vorschau, Benutzerliste + Rollen-Edit, Kontaktanfragen Detail & Antwort-Workflow, Newsletter-Gruppen-Management, Config-Editor inklusive Satzung-PDF-Feld). Unit- / Integrationstests für ViewModels.
- Phase C — Harden, UX & Tests (1 Woche): Validierung, Fehlermeldungen, Offline-Caching (verschlüsselt für geschützte Daten), Compose-UI-Tests, Accessibility-, Performance-Feinschliff.
Detaillierte Aufgaben (priorisiert):
- A1: Audit `CmsViewModel`-State vs. Backend-Responses — fehlen Felder/Fehlerfälle? (bereits teilweise umgesetzt)
- A2: Prüfen, ob API-Fehler (4xx/5xx) sauber an `FormMessages`/UI gemeldet werden — Standardisiere Fehlermeldungen.
- A3: Prüfen, ob `NativeRichTextEditor` HTML speichert, das Web-Editor-kompatibel bleibt (Quill/HTML). Schreibe Roundtrip-Tests.
- B1: News-Management
- B1.1: News-CRUD: Create/Update/Delete mit Vorschau (RichText-Preview) und Validierung (Titel Pflicht, Inhalt Mindestlänge)
- B1.2: Bulk-Aktionen: Sichtbar/Unsichtbar/ExpiresAt setzen
- B1.3: Unit-Tests für `NewsViewModel` + `CmsViewModel`-Integrationspfad
- B2: Benutzer-Management
- B2.1: Rollen-Edit (admin/vorstand/trainer/newsletter) in `CmsBenutzerScreen` (Inline-Action oder Detail-Dialog)
- B2.2: Aktiv/Inaktiv Toggle + Resend-Invite (falls API unterstützt)
- B2.3: Tests: `CmsViewModel.users()` Verhalten bei Pagination/Leeren Listen
- B3: Kontaktanfragen
- B3.1: Detailansicht mit Antwort-Option (falls Backend Mail-Sende-Endpunkt vorhanden)
- B3.2: Status-Filter (offen/beantwortet) und Bulk-Archiv
- B4: Newsletter
- B4.1: Entwurf -> Senden Flow mit Preview (falls Backend zulässt)
- B4.2: Gruppenverwaltung (CRUD) + Subscribe/Unsubscribe-Preview
- B5: Config / Seiten (Inhalte)
- B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis
- B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert)
- B5.6: Android-Startseite weiter ausbauen: Nutzer sollen Elemente und Reihenfolge der Startseite selbst zusammenstellen koennen; Detailkonzept und Feinschliff folgen spaeter
- B6: Diagnostics / Passwort-Reset-Diagnose
- B6.1: Detail-View mit exportierbaren Logs (bei Bedarf)
- C1: Offline-/Caching-Strategie
- C1.1: Verschlüsseltes lokales Caching für CMS-Daten (EncryptedSharedPreferences/Room)
- C1.2: Sync-Strategie: lokale Änderungen buffernd senden, Konflikt-UI
- C2: Tests & CI
- C2.1: ViewModel-Unit-Tests für alle CMS-Flows
- C2.2: Compose-UI-Tests für kritische Pfade (News erstellen, Benutzerrolle ändern, Config speichern)
- C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
Minor UX-Verbesserungen (parallel möglich):
- konsistente Buttons/Labels (`Speichern` vs `Inhalt speichern`), Ladezustand-UI, einzeilige Success-/Error-Banner, Inline-Validierungen.
Deliverables & Milestones:
- M1 (nach Analyse): Priorisierte Aufgabenliste + Schätzung (mehrere PRs)
- M2 (nach MVP-Implementierung): News + Benutzer + ContactRequests + Config Editor + Tests (smoke)
- M3 (Final): Offline, UI-Tests, Accessibility, Performance
Zeitplanung (empfohlen):
- Analyse: 2 Arbeitstage
- MVP-Implementierung: 710 Arbeitstage
- Hardening + Tests: 35 Arbeitstage
Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Tracker (oder als separate TODOs) ein und beginne mit A1/A2.
**TODO (zum Abhaken) — CMS-Implementierung**
- [x] A1: Audit `CmsViewModel` vs Backend-Responses (Fehleraggregation implementiert)
- [x] A2: Standardisiere API-Fehlerdarstellung in UI (`FormMessages` / globale Errors)
- [x] A3: Roundtrip-Tests `NativeRichTextEditor` ↔ Backend-HTML (Kompatibilität / Quill)
- [x] B1: News-Management
- [x] B1.1: News-CRUD (Create/Update/Delete) mit RichText-Vorschau
- [x] B1.2: Bulk-Aktionen (sichtbar/unsichtbar, expiresAt)
- [x] B1.3: Unit-Tests für `NewsViewModel`
- [x] B2: Benutzer-Management
- [x] B2.1: Rollen-Edit (Inline oder Detail-Dialog)
- [x] B2.2: Aktiv/Inaktiv Toggle, Resend-Invite
- [x] B2.3: Tests für Pagination/Leere Listen
- [x] B3: Kontaktanfragen
- [x] B3.1: Detailansicht + Antwort-Option
- [x] B3.2: Status-Filter + Archiv
- [ ] B4: Newsletter
- [x] B4.1: Entwurf → Senden Flow mit Preview
- [x] B4.2: Gruppenverwaltung (CRUD)
- [x] B5: Config / Seiten
- WebStatus: Die WebUI bietet bereits umfassende CMSUIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSVImport/Export, TabbedUIs, ImageUpload, nativelike Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSVExport/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher ConfigEditor.
- AndroidStatus: Implementiert — die AndroidApp enthält native CMSScreens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/LoadFlows via `CmsViewModel`.
- Umsetzung (B5.x):
- [x] B5.1: `cms/startseite` (StartseitenLayout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`).
- [x] B5.2: `cms/vereinsmeisterschaften` — CSVParser/CSVSave integration and modal CRUD (native UI present).
- [x] B5.3: `cms/sportbetrieb` — Tabbed UI reusing `Termine`, `Mannschaften`, `Spielplan` components.
- [x] B5.4: `cms/einstellungen` — Tabbed config editor with Vereinsdaten/Training/Trainer/Mitgliedschaft and save.
- [x] B5.5: Roundtrip & Tests — basic ViewModel unit tests and roundtrip checks exist; Compose UI smoke tests remain for hardening.
- [x] B5.6: Startseite weiter ausgebaut — zusaetzliche Elemente (`training`, `links`, `vereinsmeisterschaften`) sind konfigurierbar; Android kann Reihenfolge/Sichtbarkeit lokal speichern und Web nutzt Marker (`cookie`, `eingeloggt`) mit marker-spezifischer Persistenz: `eingeloggt` wird als individuelles User-Setting serverseitig gespeichert, `cookie` wird ausschliesslich im Browser-Cookie gehalten. Neu umgesetzt: konfigurierbare Startseiten-Widgets vom Typ `spielplan_team` (Saison + Mannschaft beim Hinzufuegen waehlbar, spaeter jederzeit aenderbar, mehrfach pro Startseite moeglich, persistiert ueber `key` + `config`).
- [x] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
- WebStatus: `cms/passwort-reset-diagnose` zeigt vollständige DiagnoseUI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren ResetVersuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
- AndroidStatus: umgesetzt — native DiagnoseUI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und ShareExport der maskierten Logs.
- Konkrete AndroidToDos (B6.x):
- [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, StatusBadges und Details.
- [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: EMail Maskierung beibehalten.
- [x] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
- Umgesetzt: EncryptedSharedPreferences-basierter Offline-Cache mit Zeitstempel/TTL pro Cache-Key (CMS standard 24h, Reset-Diagnose 6h).
- Umgesetzt: Fallback auf verschlüsselte Cache-Daten bei Ladefehlern nur innerhalb der TTL, um veraltete geschützte CMS-Daten zu begrenzen.
- Umgesetzt: Gezielte Cache-Invalidierung bei schreibenden CMS-Operationen (Konfiguration, Benutzerverwaltung, Kontaktanfragen, Newsletter, interne News), damit Offline-Daten nach Änderungen konsistent bleiben.
- Umgesetzt: Passwort-Reset-Diagnose-Cache wird nur für den Standardfilter (ohne Suchbegriff) verwendet, um falsche Treffer bei gefilterten Diagnosen zu vermeiden.
- [x] C2: Tests & CI
- [x] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
- Status: `:app:testLocalDebugUnitTest` läuft grün; `CmsViewModelTest` wurde auf aktuelle Repository-Signaturen und vollständige `load()`-Abhängigkeiten (inkl. `vereinsmeisterschaften`) aktualisiert.
- [x] C2.2: Compose-UI-Tests für kritische Flows
- Status: neuer Instrumentation-Test für `CmsPasswordResetDiagnosticsScreen` ergänzt (`diagnosticsScreen_showsFilterAndAttemptDetails`) und gezielt per `connectedLocalDebugAndroidTest` erfolgreich ausgeführt.
- [x] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
- Status: androidTest-ApiService-Stubs und Hilt-Testmodul auf neue `passwordResetDiagnostics(email, failedOnly)`-Signatur erweitert; `:app:assembleLocalDebugAndroidTest` läuft grün.
Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen.

84
ANDROID_PORT_TODO.md Normal file
View File

@@ -0,0 +1,84 @@
ANDROID App - 1:1 Portierung der Web-UI (TODO)
Ziel: Die Web-UI des Projekts 1:1 in eine native (oder cross-platform) Android-App überführen, inklusive Farben, Designsystem und aller Funktionalitäten.
1) Analyse Codebasis & Assets
- Analysiere `package.json`, `nuxt.config.js`, `tailwind.config.js` und zentrale Server-/API-Endpunkte.
- Liste alle verwendeten Farben, CSS-Variablen, Tailwind-Konfigurationen.
- Sammle alle statischen Assets: Bilder, Icons, SVGs, Fonts, PDF-Dokumente.
- Identifiziere dynamische Komponenten: Formulare, Rich-Text-Editor, Uploads, Kalender, Navigation.
2) Projektziele und Scope
- Entscheide: Native Android (Kotlin/Jetpack Compose) oder Cross-Platform (React Native, Flutter, Kotlin Multiplatform).
- Priorisiere Features für MVP vs. Post-Launch.
3) Designsystem und Farben extrahieren
- Extrahiere Farbpalette, Typografie, Abstände, Buttons, Karten, Form-Controls.
- Erstelle eine Design-Token-Liste (Hex/RGBA, Namen, Einsatzbereiche).
4) Technologie-Stack wählen
- Empfohlene Optionen: Kotlin + Jetpack Compose (native), Flutter (UI-First), React Native (Wiederverwendung von JS/nuxt-Logik).
- Bibliotheken: Navigation, HTTP-Client, Bild-Handling, Auth (WebAuthn falls nötig), Local DB.
5) Android-Projekt aufsetzen
- Erstelle Projekt-Scaffold, CI-Build, Signing-Config.
6) Theme & Farben implementieren
- Implementiere App-Theme mit Farben/Typografie-Token.
7) Navigation-Struktur implementieren
- Bottom/Navigations-Drawer/Stack wie Web-Navigation abbilden.
8) Screens für Seiten anlegen
- Erstelle Screens für: Startseite, Termine, Spielplan, Galerie, Kontakt, News, Mitgliedschaft, Login, CMS-Bereiche.
9) UI-Komponenten portieren
- Navbar, Footer, Cards, Image-Grid, Modal/Dialog, Rich-Text-Viewer/Editor, Date-Picker, Tabellen.
10) Formulare & Validierung implementieren
- Registrieren, Login, Passwort vergessen, Mitgliedschaftsformulare mit Client- und Server-Validierung.
11) Authentifizierungs-Flow implementieren
- JWT / Session, OAuth oder WebAuthn falls benötigt; Token-Handling sicher speichern.
12) API-Client implementieren
- Einheitlicher HTTP-Client, Error-Handling, Retry-Strategien, Pagination.
13) Bilderupload & Storage einrichten
- Multi-part Upload, Progress, Bildkompression, lokale Cache-Strategie.
14) Offline-Support und Caching
- Caching von API-Responses, Bild-Caching, Sync-Strategie für Formulare.
15) Lokalisierung und Texte prüfen
- Alle statischen Texte extrahieren, deutsche Strings prüfen und in Resource-Files ablegen.
16) Accessibility-Prüfung und Anpassungen
- Farbkontrast, Touch-Targets, Screenreader-Labels.
17) Unit- und UI-Tests schreiben
- Komponenten- und Integrations-Tests, E2E (falls möglich).
18) Performance-Optimierung durchführen
- Bilder, Netzwerk, Render-Perf.
19) CI/CD für Builds einrichten
- GitHub Actions / GitLab CI: Build, Test, Lint, Release.
20) Play Store Release vorbereiten
- App-Icons, Screenshots, Privacy-Policy, Datensparsamkeit.
21) Monitoring & Crash-Reporting einrichten
- Sentry / Firebase Crashlytics, Analytics.
22) Dokumentation: Setup & Architektur
- README, Architekturdiagramm, API-Spec, Onboarding-Guide.
23) Design Review und Abnahme
- UX/Design-Review mit Stakeholdern.
24) Launch und Feedbackrunde durchführen
- Release-Notes, Feedback-Formular, Bug-Fixing-Plan.
Datei erstellt: Bitte bestätige, wenn ich mit der in-depth Analyse der Codebasis und Assets beginnen soll (Suche nach Farben, verwendeten Komponenten, Images, Fonts, relevanten Scripts).

58
ANDROID_REPO_ENDPOINTS.md Normal file
View File

@@ -0,0 +1,58 @@
Repo API Endpoints — Übersicht
Hinweis: Viele Frontend-Requests verwenden relative Pfade (`/api/...`) und Nuxt's `NUXT_PUBLIC_BASE_URL`.
Öffentliche/Frontend-Endpunkte (häufig genutzt):
- GET /api/config
- GET /api/news-public
- GET /api/news
- GET /api/termine
- GET /api/spielplaene
- GET /api/spielplan
- GET /api/mannschaften
- GET /api/galerie
- GET /api/media/galerie/{id}
- GET /api/personen/{filename}?width=...&height=...
- POST /api/contact
- POST /api/news (CMS)
Galerie / Media:
- POST /api/galerie/upload
- GET /api/galerie/list
- GET /api/galerie/[id]
- DELETE /api/galerie/[id]
Authentifizierung:
- POST /api/auth/login
- POST /api/auth/logout
- POST /api/auth/register
- POST /api/auth/reset-password
- GET /api/auth/status
- POST /api/auth/passkeys/authentication-options (Passkeys start: server returns WebAuthn options)
- POST /api/auth/passkeys/login (Passkeys finish: credential verification)
CMS / geschützte Endpunkte (erfordern Auth):
- GET /api/cms/* (z.B. /api/cms/users/list, /api/cms/contact-requests)
- POST /api/cms/save-csv
- POST /api/cms/upload-spielplan-pdf
- POST /api/cms/satzung-upload
- POST /api/members, DELETE /api/members, POST /api/members/bulk
- POST /api/membership/update-status
- POST /api/termine-manage, DELETE /api/termine-manage, GET /api/termine-manage
Weitere (Datei-Uploads, Personen):
- POST /api/personen/upload
- GET /api/app/version
- Various CMS-specific routes under /api/cms
Auth-Anforderungen & Hinweise:
- Frontend nutzt `$fetch('/api/...')` (Nuxt) — serverseitig vermutlich Session-Cookie oder JWT.
- `stores/auth.js` verwendet `/api/auth/status` to check login state and `passkeyLogin()` which calls `/api/auth/passkeys/*`.
- Passkeys-Flow verwendet `@simplewebauthn/browser` on web; Android port should support FIDO2 / Passkeys (Google Passkeys API) or provide password fallback.
- CMS- und Manage-Endpunkte require authentication and role checks (admin/vorstand etc.).
Empfehlung für Android-Client:
- Nutze Retrofit/OkHttp mit anpassbarem Auth-Interceptor (Cookie-jar or token storage). Prüfe, ob Server bevorzugt Cookies (then use CookieJar) or JWT Authorization header.
- Implementiere Passkeys via Android FIDO2 / Passkeys APIs as optional fast-login path; for servers expecting WebAuthn payloads adapt encoding accordingly.
Datei automatisch erzeugt — wenn du möchtest, kann ich nun alle Dateien in `public/` und `assets/` auflisten und exportieren (Bilder, Fonts, PDFs).

View File

@@ -0,0 +1,92 @@
# Play Store Assets - Harheimer TC Android
## 1) Datenschutzerklaerung (Web-URL)
Empfohlene URL fuer Play Console:
- https://harheimertc.de/datenschutz
Die Seite ist in der Web-App als eigene Route vorhanden.
## 1b) Konto-Loeschung (Web-URL)
Empfohlene URL fuer Play Console:
- https://harheimertc.de/konto-loeschen
Die Seite beschreibt den Loeschprozess und Kontaktweg fuer App- und Webkonto.
## 2) Logo / Grafiken
### Pflicht
- App-Icon (Play): 512 x 512 PNG
### Optional, aber empfohlen
- Feature Graphic: 1024 x 500 PNG
### Generierung
Im Repo ist ein Script vorhanden, das aus dem Vereinslogo fertige Dateien erzeugt:
```bash
./scripts/playstore-assets.sh
```
Ausgabe in:
- android-app/playstore-assets/generated/playstore-icon-512.png
- android-app/playstore-assets/generated/playstore-feature-graphic-1024x500.png
## 3) Screenshots (anonymisiert)
### Zielgroessen fuer Store-Upload
- Telefon (Portrait): 1080 x 1920
- Medium 7" Tablet (Portrait): 1200 x 1920
- 10" Tablet (Portrait): 1600 x 2560
Alle Dateien als PNG oder JPEG.
### Anonymisierung
Script fuer schwarze halbtransparente Balken ueber sensible Bereiche:
```bash
./scripts/anonymize-playstore-screenshot.sh <input.png> <output.png> 'x,y,w,h;x,y,w,h'
```
Beispiel:
```bash
./scripts/anonymize-playstore-screenshot.sh \
android-app/playstore-assets/raw/screen1.png \
android-app/playstore-assets/anon/screen1-anon.png \
'68,118,520,72;70,706,560,98'
```
### Zielprofile erzeugen (Telefon, 7", 10")
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
```bash
./scripts/playstore-screenshot-sizes.sh
```
Optional mit eigenen Ordnern:
```bash
./scripts/playstore-screenshot-sizes.sh \
--input-dir android-app/playstore-assets/anon \
--output-dir android-app/playstore-assets/final
```
Output:
- android-app/playstore-assets/final/phone
- android-app/playstore-assets/final/tablet-7
- android-app/playstore-assets/final/tablet-10
## 4) Upload in Play Console
- Datenschutzerklaerung: URL eintragen
- Konto-Loeschung: URL eintragen
- App-Icon: playstore-icon-512.png
- Feature Graphic: playstore-feature-graphic-1024x500.png
- Screenshots Telefon: Dateien aus `.../final/phone`
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`

View File

@@ -0,0 +1,289 @@
import java.util.Properties
plugins {
id("com.android.application")
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.dagger.hilt.android")
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
.orElse("https://harheimertc.tsschulz.de/")
.get()
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
.orElse("https://harheimertc.de/")
.get()
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
.orElse("")
.get()
val androidVersionCode = providers.gradleProperty("ANDROID_VERSION_CODE")
.orElse("2")
.get()
.toInt()
val androidVersionName = providers.gradleProperty("ANDROID_VERSION_NAME")
.orElse("1.0.0")
.get()
val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
.orElse("true")
.get()
.toBoolean()
val localSigningProperties = Properties().apply {
val localSigningFile = rootProject.file("gradle-local.properties")
if (localSigningFile.exists()) {
localSigningFile.inputStream().use { load(it) }
}
}
fun signingProperty(name: String): String? =
providers.gradleProperty(name).orNull
?: providers.environmentVariable(name).orNull
?: localSigningProperties.getProperty(name)
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.isNullOrBlank()
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
doFirst {
if (!hasReleaseSigning) {
throw GradleException(
"Production release signing is not configured. " +
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
)
}
}
}
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
doFirst {
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
throw GradleException(
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
)
}
}
}
android {
namespace = "de.harheimertc"
compileSdk = 35
defaultConfig {
applicationId = "de.harheimertc"
minSdk = 24
targetSdk = 35
versionCode = androidVersionCode
versionName = androidVersionName
}
signingConfigs {
create("release") {
if (hasReleaseSigning) {
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = releaseMinifyEnabled
isShrinkResources = false
ndk {
// Generate a native debug symbols archive for Play Console uploads.
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
}
}
flavorDimensions += "environment"
productFlavors {
create("local") {
dimension = "environment"
applicationIdSuffix = ".local"
versionNameSuffix = "-local"
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
buildConfigField("String", "SENTRY_DSN", "\"\"")
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
create("instantTest") {
dimension = "environment"
applicationIdSuffix = ".test"
versionNameSuffix = "-test"
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
manifestPlaceholders["usesCleartextTraffic"] = "false"
}
create("production") {
dimension = "environment"
buildConfigField("String", "API_BASE_URL", "\"$productionApiBaseUrl\"")
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
manifestPlaceholders["usesCleartextTraffic"] = "false"
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests.all {
// allow Byte Buddy experimental features for newer JVMs
it.jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
}
}
}
val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("packageNativeDebugSymbolsForProductionRelease") {
group = "distribution"
description = "Packages production release native libraries into a Play Console debug symbols zip."
dependsOn(":app:mergeProductionReleaseNativeLibs")
from(layout.buildDirectory.dir("intermediates/merged_native_libs/productionRelease/mergeProductionReleaseNativeLibs/out/lib"))
destinationDirectory.set(layout.buildDirectory.dir("outputs/native-debug-symbols/productionRelease"))
archiveFileName.set("native-debug-symbols.zip")
}
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
group = "distribution"
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
dependsOn(":app:bundleProductionRelease")
dependsOn(packageNativeDebugSymbolsForProductionRelease)
doLast {
val outputDir = layout.buildDirectory.dir("outputs/playstore/productionRelease").get().asFile
outputDir.mkdirs()
val bundleFile = layout.buildDirectory.file("outputs/bundle/productionRelease/app-production-release.aab").get().asFile
if (bundleFile.exists()) {
bundleFile.copyTo(outputDir.resolve(bundleFile.name), overwrite = true)
}
val mappingFile = layout.buildDirectory.file("outputs/mapping/productionRelease/mapping.txt").get().asFile
if (mappingFile.exists()) {
mappingFile.copyTo(outputDir.resolve("mapping.txt"), overwrite = true)
}
val nativeSymbolsZip = layout.buildDirectory.file("outputs/native-debug-symbols/productionRelease/native-debug-symbols.zip").get().asFile
if (nativeSymbolsZip.exists()) {
nativeSymbolsZip.copyTo(outputDir.resolve("native-debug-symbols.zip"), overwrite = true)
}
println("Play Store artifacts prepared in: ${outputDir.absolutePath}")
}
}
tasks.matching {
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
}.configureEach {
dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.appcompat:appcompat:1.6.1")
// Compose
implementation("androidx.compose.ui:ui:1.5.0")
implementation("androidx.compose.ui:ui-tooling-preview:1.5.0")
debugImplementation("androidx.compose.ui:ui-tooling:1.5.0")
implementation("androidx.compose.material3:material3:1.1.0")
implementation("androidx.navigation:navigation-compose:2.6.0")
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
// Hilt
implementation("com.google.dagger:hilt-android:2.59.2")
ksp("com.google.dagger:hilt-compiler:2.59.2")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
// Passkeys / Credential Manager
implementation("androidx.credentials:credentials:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
// Coil
implementation("io.coil-kt:coil-compose:2.4.0")
// Crash reporting
implementation("io.sentry:sentry-android:8.42.0")
// Push notifications
implementation("com.google.firebase:firebase-messaging:25.0.2")
// Room
implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
// WorkManager, DataStore
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Testing (skeleton)
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("io.mockk:mockk:1.13.7")
// Compose UI testing
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
// Hilt testing
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated
androidTestImplementation("com.google.dagger:hilt-android:2.59.2")
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.0")
}

Binary file not shown.

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

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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="false">
</application>
</manifest>

View File

@@ -0,0 +1,6 @@
// Disabled TestBindingsModule — replaced by TestHiltModules.kt
// Kept as an empty placeholder to avoid accidental compilation of the previous
// broken test module. Refer to TestHiltModules.kt for test bindings.
package de.harheimertc.test
// Intentionally empty

View File

@@ -0,0 +1,90 @@
package de.harheimertc.test
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthUserDto
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.data.SessionRefresher
import dagger.hilt.InstallIn
import retrofit2.Response
import javax.inject.Singleton
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.PasskeyRepository
import de.harheimertc.repositories.AuthRepository as RepoAuthRepository
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [de.harheimertc.data.NetworkModule::class, de.harheimertc.di.RepositoryModule::class]
)
object TestHiltModules {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder().build()
@Provides
@Singleton
fun provideApiService(): ApiService {
val handler = InvocationHandler { _, method: Method, args: Array<Any>? ->
when (method.name) {
"login" -> Response.success(LoginResponse(success = true, accessToken = "test-token", refreshToken = "r", sessionId = "s", user = AuthUserDto(id = "1", email = "test@example.com", name = "Test")))
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
}
}
return Proxy.newProxyInstance(
ApiService::class.java.classLoader,
arrayOf(ApiService::class.java),
handler,
) as ApiService
}
@Provides
@Singleton
fun provideAuthRepository(): AuthRepository = object : AuthRepository {
private var token: String? = "test-token"
private var refresh: String? = "r"
override fun getToken(): String? = token
override fun getRefreshToken(): String? = refresh
override fun getSessionId(): String? = "s"
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
token = accessToken
refresh = refreshToken
}
override fun clearSession() { token = null; refresh = null }
override fun ensureDeviceKey(): String? = null
override fun getDevicePublicKey(): String? = null
override fun signWithDeviceKey(data: ByteArray): ByteArray? = null
}
@Provides
@Singleton
fun provideSessionRefresher(auth: AuthRepository, moshi: Moshi): SessionRefresher = SessionRefresher(auth, moshi)
@Provides
@Singleton
fun provideLoginRepository(api: ApiService, auth: AuthRepository, sessionRefresher: SessionRefresher): LoginRepository {
return LoginRepository(api, auth, sessionRefresher)
}
@Provides
@Singleton
fun providePasskeyRepository(api: ApiService, auth: AuthRepository): PasskeyRepository {
return PasskeyRepository(api, auth)
}
}

View File

@@ -0,0 +1,5 @@
package de.harheimertc.ui
import androidx.activity.ComponentActivity
class TestActivity : ComponentActivity()

View File

@@ -0,0 +1,113 @@
package de.harheimertc.ui.screens.cms
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.*
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CmsActivateResendTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun activateAndResend_buttonsAreClickable() {
composeTestRule.setContent {
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Deaktivieren") }
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Invite erneut") }
}
// wait until nodes appear to avoid race conditions on slower devices
fun waitForText(text: String, timeoutMs: Long = 15000L) {
try {
composeTestRule.waitUntil(timeoutMs) {
try {
composeTestRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty()
} catch (_: AssertionError) {
false
}
}
} catch (e: Throwable) {
// dump semantics tree for debugging before failing
try {
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE")
} catch (_: Throwable) { /* best-effort logging */ }
throw AssertionError("Timed out waiting for text: '$text'")
}
}
// helper: find the nearest parent node that has a click action
fun findClickableParent(text: String): SemanticsNodeInteraction {
val all = composeTestRule.onAllNodes(hasText(text))
if (all.fetchSemanticsNodes().isEmpty()) {
try {
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NOT-FOUND-$text")
} catch (_: Throwable) { }
throw AssertionError("No node found with text '$text'")
}
// Log matches for debugging
try {
val matches = all.fetchSemanticsNodes()
Log.d("CmsActivateResendTest", "Found ${matches.size} node(s) for text '$text'")
matches.forEachIndexed { i, n -> Log.d("CmsActivateResendTest", "Match[$i]: ${n}") }
} catch (_: Throwable) { /* ignore logging failures */ }
var node = try {
// prefer the single-node API, but fall back to the first match if ambiguous
composeTestRule.onNode(hasText(text))
} catch (_: AssertionError) {
all[0]
}
// climb a few parents to find the clickable wrapper
repeat(8) {
try {
node.assert(hasClickAction())
try { Log.d("CmsActivateResendTest", "Clickable node found for '$text': ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
return node
} catch (_: AssertionError) {
try { Log.d("CmsActivateResendTest", "Node not clickable yet, current node: ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
node = node.onParent()
}
}
try {
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NO-CLICK-$text")
} catch (_: Throwable) { }
throw AssertionError("No clickable parent found for text '$text'")
}
waitForText("Deaktivieren")
val deactivateNode = findClickableParent("Deaktivieren")
deactivateNode.assertExists()
deactivateNode.assertIsDisplayed()
deactivateNode.assert(hasClickAction())
composeTestRule.waitForIdle()
try {
deactivateNode.performClick()
} catch (e: Throwable) {
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-DEACTIVATE")
throw e
}
waitForText("Invite erneut")
val inviteNode = findClickableParent("Invite erneut")
inviteNode.assertExists()
inviteNode.assertIsDisplayed()
inviteNode.assert(hasClickAction())
composeTestRule.waitForIdle()
try {
inviteNode.performClick()
} catch (e: Throwable) {
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-INVITE")
throw e
}
}
}

View File

@@ -0,0 +1,392 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.navigation.compose.rememberNavController
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequest
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.ContactResponse
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.MembersResponse
import de.harheimertc.data.MembershipRequest
import de.harheimertc.data.MembershipResponse
import de.harheimertc.data.NewsletterCreateRequest
import de.harheimertc.data.NewsletterCreateResponse
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.NewsletterSendResponse
import de.harheimertc.data.NewsletterSubscriptionRequest
import de.harheimertc.data.NewsPublicResponse
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
import de.harheimertc.data.PasskeysResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.ProfileResponse
import de.harheimertc.data.ProfileUpdateRequest
import de.harheimertc.data.PublicGalleryImageDto
import de.harheimertc.data.GalleryListResponse
import de.harheimertc.data.GalleryUploadResponse
import de.harheimertc.data.RefreshRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RemovePasskeyRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TeamTableResponse
import de.harheimertc.data.TermineResponse
import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
@RunWith(AndroidJUnit4::class)
class CmsExistingScreensSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsDashboard_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
}
@Test
fun cmsStartseite_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsInhalte_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsVereinsmeisterschaften_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.saveCsvCalls)
}
@Test
fun cmsSportbetrieb_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsEinstellungen_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsMitgliederverwaltung_clickFreischalten() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Freischalten").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserActiveCalls)
}
@Test
fun cmsBenutzer_clickRollenSpeichern() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Rollen").performClick()
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserRolesCalls)
}
@Test
fun cmsContactRequests_replySenden() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Antworten").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
composeTestRule.onNodeWithText("Senden").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.replyContactCalls)
}
@Test
fun cmsNewsletter_createAndSave() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
}
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.createNewsletterCalls)
}
@Test
fun cmsPasswordResetDiagnostics_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
}
private fun createViewModel(api: RecordingApiService): CmsViewModel {
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repository = CmsRepository(api, cache)
return CmsViewModel(repository)
}
private fun renderWithState(viewModel: CmsViewModel) {
val readyState = CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
),
contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
),
newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
),
newsletterGroups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
),
passwordResetAttempts = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
),
news = emptyList(),
meisterschaften = listOf(
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
),
)
val field = CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
}
}
private class RecordingApiService : ApiService {
var updateConfigCalls = 0
var saveCsvCalls = 0
var updateUserActiveCalls = 0
var updateUserRolesCalls = 0
var replyContactCalls = 0
var createNewsletterCalls = 0
private val config = ConfigResponse()
private val users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
)
private val contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
)
private val newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
)
private val groups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
)
private val diagnostics = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
)
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
updateConfigCalls++
return Response.success(request)
}
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> =
Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\"".toResponseBody(null))
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
saveCsvCalls++
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
}
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
updateUserRolesCalls++
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
}
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
updateUserActiveCalls++
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
}
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
replyContactCalls++
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
}
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
createNewsletterCalls++
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
}
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
}

View File

@@ -0,0 +1,123 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.navigation.compose.rememberNavController
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.repositories.CmsRepository
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
@RunWith(AndroidJUnit4::class)
class CmsPasswordResetDiagnosticsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
val api = createDiagnosticsApiService()
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repo = CmsRepository(api, cache)
val viewModel = CmsViewModel(repo)
composeTestRule.setContent {
val navController = rememberNavController()
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.waitUntil(15_000) {
try {
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
true
} catch (_: Throwable) {
false
}
}
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
// Trigger a manual refresh to validate the main interaction path.
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
composeTestRule.waitForIdle()
}
private fun createDiagnosticsApiService(): ApiService {
val attempt = PasswordResetAttemptDto(
requestId = "req-1",
startedAt = "2026-05-29T10:15:00Z",
emailMasked = "m***@example.com",
ip = "127.0.0.1",
failed = true,
steps = listOf(
PasswordResetStepDto(
ts = "2026-05-29T10:15:01Z",
step = "mail_configuration",
status = "failed",
reason = "smtp_credentials_missing",
),
),
)
val handler = InvocationHandler { _, method: Method, _ ->
when (method.name) {
"config" -> Response.success(ConfigResponse())
"users" -> Response.success(CmsUsersResponse())
"cmsUsers" -> Response.success(CmsUsersResponse())
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
"passwordResetDiagnostics" -> Response.success(
PasswordResetDiagnosticsResponse(
retentionHours = 72,
searchedEmail = "",
matchingUsers = listOf(
de.harheimertc.data.PasswordResetMatchingUserDto(
id = "u1",
name = "Max Muster",
email = "max@example.com",
active = true,
),
),
attempts = listOf(attempt),
),
)
"vereinsmeisterschaften" -> Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n".toResponseBody(null))
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
}
}
return Proxy.newProxyInstance(
ApiService::class.java.classLoader,
arrayOf(ApiService::class.java),
handler,
) as ApiService
}
}

View File

@@ -0,0 +1,85 @@
package de.harheimertc.ui.screens.cms
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CmsRolesDialogTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
class FakeVm {
var calledId: String? = null
var calledRoles: List<String>? = null
fun updateUserRoles(id: String, roles: List<String>) {
calledId = id
calledRoles = roles
}
}
@Test
fun rolesDialog_callsUpdateUserRoles() {
val fake = FakeVm()
val initialRoles = listOf("admin")
composeTestRule.setContent {
val show = remember { mutableStateOf(false) }
val selected = remember { mutableStateListOf<String>().apply { addAll(initialRoles) } }
Column {
Button(onClick = { show.value = true }) { Text("Rollen") }
if (show.value) {
AlertDialog(
onDismissRequest = { show.value = false },
title = { Text("Rollen bearbeiten") },
text = {
Column(modifier = Modifier.padding(4.dp)) {
// simple checkbox row for admin only (representative)
Row {
Checkbox(checked = selected.contains("admin"), onCheckedChange = { checked ->
if (checked) selected.add("admin") else selected.remove("admin")
})
Text("admin", modifier = Modifier.padding(start = 8.dp))
}
}
},
confirmButton = {
Button(onClick = {
fake.updateUserRoles("42", selected.toList())
show.value = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { show.value = false }) { Text("Abbrechen") } }
)
}
}
}
// Open dialog
composeTestRule.onNodeWithText("Rollen").performClick()
// Save immediately (we keep admin preselected)
composeTestRule.onNodeWithText("Speichern").performClick()
assert(fake.calledId == "42")
assert(fake.calledRoles?.contains("admin") == true)
}
}

View File

@@ -0,0 +1,25 @@
package de.harheimertc.ui.screens.cms
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CmsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsScreen_placeholder() {
composeTestRule.setContent {
Text("CMS Placeholder")
}
composeTestRule.onNodeWithText("CMS Placeholder").assertExists()
}
}

View File

@@ -0,0 +1,156 @@
package de.harheimertc.ui.screens.cms
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.core.app.ApplicationProvider
import com.squareup.moshi.Moshi
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertTrue
import org.junit.runner.RunWith
import retrofit2.Response
import okhttp3.MultipartBody
import okhttp3.RequestBody
import de.harheimertc.data.*
import kotlinx.coroutines.flow.MutableStateFlow
import de.harheimertc.repositories.CmsRepository
import androidx.navigation.compose.rememberNavController
@RunWith(AndroidJUnit4::class)
class CmsStartseiteSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsStartseite_rendersWithDefaultState() {
// prepare a minimal fake ApiService that returns empty/neutral responses
val fakeApi = object : ApiService {
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
}
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val moshi = Moshi.Builder().build()
val cache = SecureOfflineCache(context, moshi)
val repo = CmsRepository(fakeApi, cache)
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
// set a ready state to avoid waiting for async network loads in Vm.init
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = emptyList(),
contactRequests = emptyList(),
newsletters = emptyList(),
newsletterGroups = emptyList(),
passwordResetAttempts = emptyList(),
news = emptyList(),
)
try {
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
val current = field.get(vm) as? MutableStateFlow<*>
if (current is MutableStateFlow<*>) {
@Suppress("UNCHECKED_CAST")
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
}
} catch (_: Throwable) { /* best-effort, continue */ }
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
}
// dump semantics tree for debugging
try {
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
} catch (_: Throwable) { }
// wait for the main title and info rows to appear
fun waitForText(text: String, timeoutMs: Long = 20000L) {
composeTestRule.waitUntil(timeoutMs) {
try {
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
} catch (_: AssertionError) { false }
}
}
waitForText("Startseite")
waitForText("Öffentliche")
// basic assertions (use substring matching)
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
}
}

View File

@@ -0,0 +1,144 @@
package de.harheimertc.ui.screens.cms
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CmsUiAutomatorClickTest {
@Test
fun clickThroughExistingCmsPages_andTrySave() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val device = UiDevice.getInstance(instrumentation)
val packageName = "de.harheimertc.local"
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
context.startActivity(launchIntent)
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
clickText(device, "Intern")
clickText(device, "CMS")
openCmsCard(device, "Startseite")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Inhalte")
clickIfPresent(device, "Inhalte speichern")
backToCmsDashboard(device)
openCmsCard(device, "Vereinsmeisterschaften")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Sportbetrieb")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Einstellungen")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Mitgliederverwaltung")
clickIfPresent(device, "Freischalten")
backToCmsDashboard(device)
openCmsCard(device, "Kontaktanfragen")
clickIfPresent(device, "Antworten")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
openCmsCard(device, "Newsletter")
clickIfPresent(device, "Newsletter erstellen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
if (openCmsCardIfAvailable(device, "Benutzer")) {
clickIfPresent(device, "Rollen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
}
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
}
private fun openCmsCard(device: UiDevice, label: String) {
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
clickText(device, label)
}
}
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
if (clickIfPresent(device, label, 1500)) return true
return clickTextWithScroll(device, label)
}
private fun backToCmsDashboard(device: UiDevice) {
if (!clickIfPresent(device, "CMS", 3000)) {
device.pressBack()
device.waitForIdle()
}
}
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
requireNotNull(obj) { "Text nicht gefunden: $text" }
obj.click()
device.waitForIdle()
return obj
}
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
if (obj != null) {
obj.click()
device.waitForIdle()
return true
}
return false
}
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
if (clickIfPresent(device, text, 1500)) return true
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
return false
}
}

View File

@@ -0,0 +1,24 @@
package de.harheimertc.ui.screens.gallery
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class GalleryScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun galleryScreen_rendersPlaceholder() {
composeTestRule.setContent {
GalleryScreen()
}
composeTestRule.onRoot().assertExists()
}
}

View File

@@ -0,0 +1,26 @@
package de.harheimertc.ui.screens.home
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.*
import androidx.navigation.compose.rememberNavController
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HomeScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun homeScreen_renders() {
composeTestRule.setContent {
val navController = rememberNavController()
HomeScreen(navController = navController, showNavigationHeader = false)
}
composeTestRule.onRoot().assertExists()
}
}

View File

@@ -0,0 +1,25 @@
package de.harheimertc.ui.screens.login
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.navigation.compose.rememberNavController
import org.junit.Rule
import org.junit.Test
class LoginScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun loginScreen_showsFields() {
composeTestRule.setContent {
val navController = rememberNavController()
LoginScreen(navController = navController, showBackNavigation = false)
}
composeTestRule.onNodeWithText("E-Mail-Adresse", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Passwort", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Anmelden", useUnmergedTree = true).assertExists()
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Disable Sentry automatic initialization in debug/test builds -->
<meta-data android:name="io.sentry.auto-init" android:value="false" />
<meta-data android:name="io.sentry.dsn" android:value="" />
</application>
</manifest>

View File

@@ -0,0 +1,37 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".HarheimerApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.HarheimerTC"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name="de.harheimertc.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".notifications.HarheimerMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,51 @@
package de.harheimertc
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp
import de.harheimertc.notifications.HarheimerNotifications
import io.sentry.Sentry
import okhttp3.OkHttpClient
import javax.inject.Inject
import android.util.Log
@HiltAndroidApp
class HarheimerApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var okHttpClient: OkHttpClient
override fun onCreate() {
Log.d("HILT", "HarheimerApplication.onCreate called")
super.onCreate()
HarheimerNotifications.createChannels(this)
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
Sentry.init { options ->
options.dsn = BuildConfig.SENTRY_DSN
options.environment = BuildConfig.ENVIRONMENT_NAME.ifBlank { "production" }
options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}"
options.isEnableAutoSessionTracking = true
options.tracesSampleRate = 0.05
}
}
}
override fun newImageLoader(): ImageLoader =
ImageLoader.Builder(this)
.okHttpClient(okHttpClient)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.20)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(75L * 1024L * 1024L)
.build()
}
.crossfade(true)
.build()
}

View File

@@ -0,0 +1,61 @@
package de.harheimertc
import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.ui.theme.HarheimerTheme
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel
import dagger.hilt.android.AndroidEntryPoint
import android.util.Log
import androidx.compose.ui.platform.LocalContext
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
setContent {
App()
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
@Composable
fun App() {
HarheimerTheme {
val navController = rememberNavController()
val ctx = LocalContext.current
val activity = ctx as? ComponentActivity
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
val navigationViewModel: NavigationViewModel = hiltViewModel()
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
}
}
@Preview
@Composable
fun PreviewMain() {
App()
}

View File

@@ -0,0 +1,39 @@
package de.harheimertc.data
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccessTokenAuthenticator @Inject constructor(
private val sessionRefresher: SessionRefresher,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
return null
}
val currentAccessToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
?.takeIf(String::isNotBlank)
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 0
while (current != null) {
count += 1
current = current.priorResponse
}
return count
}
}

View File

@@ -0,0 +1,803 @@
package de.harheimertc.data
import com.squareup.moshi.Json
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.PUT
import retrofit2.http.Query
import retrofit2.http.Url
import retrofit2.http.Streaming
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import okhttp3.RequestBody
data class ContactRequest(val name: String, val email: String, val message: String)
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TerminDto(
val datum: String = "",
val uhrzeit: String? = null,
val titel: String = "",
val beschreibung: String? = null,
val kategorie: String? = null,
)
data class SpielplanResponse(
val success: Boolean = false,
val message: String? = null,
val data: List<SpielDto> = emptyList(),
val headers: List<String> = emptyList(),
val season: String? = null,
val seasons: List<SeasonDto> = emptyList(),
)
data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse(
val success: Boolean = false,
val seasons: List<String> = emptyList(),
val currentSeason: String = "",
val defaultSeason: String = "",
)
data class SpielDto(
@param:Json(name = "Termin") val termin: String = "",
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
@param:Json(name = "GastMannschaft") val gastMannschaft: String = "",
@param:Json(name = "HeimMannschaftAltersklasse") val heimAltersklasse: String = "",
@param:Json(name = "GastMannschaftAltersklasse") val gastAltersklasse: String = "",
@param:Json(name = "Altersklasse") val altersklasse: String = "",
@param:Json(name = "Liga") val liga: String = "",
@param:Json(name = "Staffel") val staffel: String = "",
@param:Json(name = "Runde") val runde: String? = null,
@param:Json(name = "SpieleHeim") val spieleHeim: String = "",
@param:Json(name = "SpieleGast") val spieleGast: String = "",
)
data class TeamTableResponse(
val success: Boolean = false,
val message: String? = null,
val season: String? = null,
val table: TeamTableDto? = null,
)
data class TeamTableDto(
val teamName: String = "",
val leagueName: String = "",
val table: LeagueTableDto? = null,
)
data class LeagueTableDto(
val leagueTable: List<LeagueTableRowDto> = emptyList(),
)
data class LeagueTableRowDto(
@param:Json(name = "table_rank") val rank: Int? = null,
@param:Json(name = "team_name") val teamName: String = "",
@param:Json(name = "meetings_count") val meetings: Int? = null,
@param:Json(name = "meetings_won") val won: Int? = null,
@param:Json(name = "meetings_tie") val tied: Int? = null,
@param:Json(name = "meetings_lost") val lost: Int? = null,
@param:Json(name = "sets_won") val setsWon: Int? = null,
@param:Json(name = "sets_lost") val setsLost: Int? = null,
@param:Json(name = "games_won") val gamesWon: Int? = null,
@param:Json(name = "games_lost") val gamesLost: Int? = null,
@param:Json(name = "points_won") val pointsWon: Int? = null,
@param:Json(name = "points_lost") val pointsLost: Int? = null,
@param:Json(name = "rise_fall_state") val movement: String? = null,
)
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto(
val id: String? = null,
val title: String = "",
val content: String = "",
val created: String? = null,
val updated: String? = null,
val author: String? = null,
val isPublic: Boolean = false,
val isHidden: Boolean = false,
val expiresAt: String? = null,
)
data class NewsResponse(
val success: Boolean = false,
val news: List<NewsDto> = emptyList(),
)
data class NewsSaveRequest(
val id: String? = null,
val title: String,
val content: String,
val isPublic: Boolean = false,
val isHidden: Boolean = false,
val expiresAt: String? = null,
)
data class PublicGalleryImageDto(
val filename: String = "",
val title: String = "",
)
data class GalleryImageDto(
val id: String = "",
val title: String = "",
val description: String = "",
val isPublic: Boolean = false,
val uploadedAt: String? = null,
val previewFilename: String? = null,
)
data class GalleryPaginationDto(
val page: Int = 1,
val perPage: Int = 10,
val total: Int = 0,
val totalPages: Int = 0,
)
data class GalleryListResponse(
val success: Boolean = false,
val images: List<GalleryImageDto> = emptyList(),
val pagination: GalleryPaginationDto = GalleryPaginationDto(),
)
data class GalleryUploadImageDto(
val id: String = "",
val title: String = "",
val isPublic: Boolean = false,
)
data class GalleryUploadResponse(
val success: Boolean = false,
val message: String? = null,
val image: GalleryUploadImageDto? = null,
)
data class MembershipRequest(
val vorname: String,
val nachname: String,
val strasse: String,
val plz: String,
val ort: String,
val geburtsdatum: String,
val email: String,
val telefon_privat: String? = null,
val telefon_mobil: String? = null,
val mitgliedschaftsart: String,
val lastschrift_erlaubt: Boolean,
val kontoinhaber: String,
val iban: String,
val bic: String? = null,
val bank: String? = null,
val datenschutz_einverstanden: Boolean,
val satzung_anerkannt: Boolean,
)
data class MembershipResponse(
val success: Boolean = false,
val message: String? = null,
val downloadUrl: String? = null,
)
data class LoginRequest(
val email: String,
val password: String,
val client: String = "android",
val deviceName: String = "Harheimer TC Android-App",
)
data class AuthUserDto(
val id: String? = null,
val email: String = "",
val name: String? = null,
val roles: List<String> = emptyList(),
)
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val sessionId: String? = null,
val user: AuthUserDto? = null,
val role: String? = null,
)
data class RefreshRequest(val refreshToken: String)
data class LogoutRequest(val refreshToken: String? = null)
data class AuthStatusResponse(
val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null,
val roles: List<String> = emptyList(),
val role: String? = null,
)
data class ResetPasswordRequest(val email: String)
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
data class SaveCsvRequest(
val filename: String,
val content: String,
)
data class SaveCsvResponse(
val success: Boolean = false,
val message: String? = null,
val writtenTo: List<String> = emptyList(),
val jsonWrittenTo: List<String> = emptyList(),
)
data class PasskeyAuthenticationOptionsRequest(
val email: String? = null,
val client: String = "android",
)
data class PasskeyRegistrationOptionsRequest(
val preferredAuthenticatorType: String? = null,
val client: String = "android",
)
data class PasskeyDto(
val id: String = "",
val credentialId: String = "",
val createdAt: String? = null,
val lastUsedAt: String? = null,
val name: String = "",
)
data class PasskeysResponse(
val success: Boolean = false,
val passkeys: List<PasskeyDto> = emptyList(),
)
data class RemovePasskeyRequest(val credentialId: String)
data class ProfileVisibilityDto(
val showEmail: Boolean = true,
val showPhone: Boolean = true,
val showAddress: Boolean = false,
val showBirthday: Boolean = true,
)
data class ProfileUserDto(
val id: String? = null,
val name: String = "",
val email: String = "",
val phone: String = "",
val geburtsdatum: String = "",
val visibility: ProfileVisibilityDto = ProfileVisibilityDto(),
val roles: List<String> = emptyList(),
val role: String? = null,
)
data class ProfileResponse(
val success: Boolean = false,
val message: String? = null,
val user: ProfileUserDto? = null,
)
data class ProfileUpdateRequest(
val name: String,
val email: String,
val phone: String? = null,
val geburtsdatum: String? = null,
val visibility: ProfileVisibilityDto,
val currentPassword: String? = null,
val newPassword: String? = null,
)
data class 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(
val name: String = "",
val dayMonth: String = "",
val inDays: Int = 0,
)
data class BirthdaysResponse(
val success: Boolean = false,
val birthdays: List<BirthdayDto> = emptyList(),
)
data class QttrSourceDto(
val url: String = "",
)
data class QttrRowDto(
val rank: Int? = null,
val playerNumber: Int? = null,
val gender: String? = null,
val playerName: String = "",
val clubName: String = "",
val currentQttr: Int? = null,
val previousQttr: Int? = null,
val birthdate: String? = null,
)
data class QttrValuesResponse(
val format: String = "",
val importedAt: String = "",
val source: QttrSourceDto = QttrSourceDto(),
val title: String? = null,
val headerCount: Int = 0,
val rowCount: Int = 0,
val rows: List<QttrRowDto> = emptyList(),
)
data class MemberDto(
val id: String? = null,
val name: String = "",
val firstName: String = "",
val lastName: String = "",
val email: String? = null,
val phone: String? = null,
val address: String? = null,
val birthday: String? = null,
val geburtsdatum: String? = null,
val source: String = "",
val notes: String = "",
val hasLogin: Boolean = false,
val editable: Boolean = false,
val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false,
val loginRoles: List<String> = emptyList(),
)
data class MembersResponse(
val success: Boolean = false,
val members: List<MemberDto> = emptyList(),
)
data class RegistrationVisibility(val showBirthday: Boolean)
data class RegistrationRequest(
val name: String,
val email: String,
val phone: String? = null,
val password: String,
val geburtsdatum: String,
val visibility: RegistrationVisibility,
)
data class TrainingLocationDto(
val name: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
)
data class TrainingTimeDto(
val id: String = "",
val tag: String = "",
val von: String = "",
val bis: String = "",
val gruppe: String = "",
val info: String? = null,
)
data class TrainingDto(
val ort: TrainingLocationDto = TrainingLocationDto(),
val zeiten: List<TrainingTimeDto> = emptyList(),
)
data class TrainerDto(
val id: String = "",
val name: String = "",
val lizenz: String = "",
val schwerpunkt: String = "",
val zusatz: String? = null,
val imageFilename: String? = null,
)
data class BoardMemberDto(
val vorname: String = "",
val nachname: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
val telefon: String = "",
val email: String = "",
val imageFilename: String? = null,
)
data class VorstandDto(
val vorsitzender: BoardMemberDto = BoardMemberDto(),
val stellvertreter: BoardMemberDto = BoardMemberDto(),
val kassenwart: BoardMemberDto = BoardMemberDto(),
val schriftfuehrer: BoardMemberDto = BoardMemberDto(),
val sportwart: BoardMemberDto = BoardMemberDto(),
val jugendwart: BoardMemberDto = BoardMemberDto(),
)
data class VereinDto(
val name: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
val useVorsitzenderAddress: Boolean = false,
)
data class WebsiteResponsibleDto(
val vorname: String = "",
val nachname: String = "",
val email: String = "",
)
data class WebsiteDto(
val verantwortlicher: WebsiteResponsibleDto = WebsiteResponsibleDto(),
)
data class SatzungDto(
val pdfUrl: String = "",
val content: String = "",
)
data class MembershipTierDto(
val id: String = "",
val typ: String = "",
val beschreibung: String? = null,
val preis: Int = 0,
val features: List<String> = emptyList(),
)
data class LinkItemDto(
val label: String = "",
val href: String = "",
val description: String = "",
val id: String = "",
)
data class LinkSectionDto(
val title: String = "",
val items: List<LinkItemDto> = emptyList(),
val id: String = "",
)
data class HomepageSectionDto(
val id: String = "",
val enabled: Boolean = true,
val key: String? = null,
val marker: String? = null,
val config: HomepageSectionConfigDto? = null,
)
data class HomepageSectionConfigDto(
val season: String? = null,
val teamName: String? = null,
val teamAgeGroup: String? = null,
)
data class HomepageDto(
val sections: List<HomepageSectionDto> = emptyList(),
)
data class HeroImageVariantDto(
val key: String = "",
val mobileWebp: String = "",
val desktopWebp: String = "",
val fallback: String = "",
)
data class HeroImagesResponse(
val variants: List<HeroImageVariantDto> = emptyList(),
)
data class SeitenDto(
val ueberUns: String = "",
val geschichte: String = "",
val ttRegeln: String = "",
val satzung: SatzungDto = SatzungDto(),
val links: String = "",
val linksStructured: List<LinkSectionDto> = emptyList(),
)
data class ConfigResponse(
val training: TrainingDto = TrainingDto(),
val trainer: List<TrainerDto> = emptyList(),
val mitgliedschaft: List<MembershipTierDto> = emptyList(),
val verein: VereinDto = VereinDto(),
val vorstand: VorstandDto = VorstandDto(),
val website: WebsiteDto = WebsiteDto(),
val seiten: SeitenDto = SeitenDto(),
val homepage: HomepageDto = HomepageDto(),
)
data class CmsUserDto(
val id: String = "",
val email: String? = null,
val name: String = "",
val roles: List<String> = emptyList(),
val role: String? = null,
val phone: String = "",
val active: Boolean = true,
val created: String? = null,
val lastLogin: String? = null,
)
data class CmsUsersResponse(val users: List<CmsUserDto> = emptyList())
data class ContactRequestDto(
val id: String = "",
val name: String = "",
val email: String = "",
val phone: String? = null,
val message: String = "",
val status: String = "",
val createdAt: String? = null,
val repliedAt: String? = null,
)
data class NewsletterDto(
val id: String = "",
val subject: String = "",
val title: String = "",
val createdAt: String? = null,
val sentAt: String? = null,
val status: String? = null,
)
data class NewsletterListResponse(
val success: Boolean = false,
val newsletters: List<NewsletterDto> = emptyList(),
)
data class NewsletterCreateRequest(
val title: String,
val content: String,
val type: String,
val targetGroup: String? = null,
val sendToExternal: Boolean? = null,
)
data class NewsletterCreateResponse(
val success: Boolean = false,
val message: String? = null,
val newsletter: NewsletterDto? = null,
)
data class NewsletterSendResponse(
val success: Boolean = false,
val message: String? = null,
val stats: Map<String, Any>? = null,
)
data class NewsletterGroupDto(
val id: String = "",
val name: String = "",
val description: String = "",
val subscribers: List<String> = emptyList(),
val createdAt: String? = null,
)
data class NewsletterGroupsResponse(
val success: Boolean = false,
val groups: List<NewsletterGroupDto> = emptyList(),
)
data class NewsletterSubscriptionRequest(
val groupId: String,
val email: String,
val name: String? = null,
)
data class PasswordResetStepDto(
val ts: String? = null,
val step: String = "",
val status: String = "",
val reason: String? = null,
val errorCode: String? = null,
val errorMessage: String? = null,
)
data class PasswordResetAttemptDto(
val requestId: String = "",
val startedAt: String? = null,
val emailMasked: String? = null,
val ip: String? = null,
val failed: Boolean = false,
val steps: List<PasswordResetStepDto> = emptyList(),
)
data class PasswordResetMatchingUserDto(
val id: String = "",
val name: String = "",
val email: String = "",
val active: Boolean = true,
val lastLogin: String? = null,
)
data class PasswordResetDiagnosticsResponse(
val retentionHours: Int = 0,
val searchedEmail: String? = null,
val matchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
val attempts: List<PasswordResetAttemptDto> = emptyList(),
)
interface ApiService {
@POST("/api/contact")
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
@GET("/api/galerie/list")
suspend fun galerieList(
@Query("page") page: Int = 1,
@Query("perPage") perPage: Int = 60,
): Response<GalleryListResponse>
@Multipart
@POST("/api/galerie/upload")
suspend fun uploadGalleryImage(
@Part image: MultipartBody.Part,
@Part("title") title: RequestBody,
@Part("description") description: RequestBody,
@Part("isPublic") isPublic: RequestBody,
): Response<GalleryUploadResponse>
@GET("/api/galerie")
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
@GET("/api/termine")
suspend fun termine(): Response<TermineResponse>
@GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
@GET("/api/spielplan/table")
suspend fun spielplanTable(
@Query("team") team: String,
@Query("season") season: String? = null,
): Response<TeamTableResponse>
@GET("/api/news-public")
suspend fun publicNews(): Response<NewsPublicResponse>
@GET("/api/news")
suspend fun memberNews(): Response<NewsResponse>
@POST("/api/news")
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/news")
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
@GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@GET("/api/mannschaften/seasons")
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
@GET("/api/config")
suspend fun config(): Response<ConfigResponse>
@GET("/api/hero-images")
suspend fun heroImages(): Response<HeroImagesResponse>
@PUT("/api/config")
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
@GET("/data/spielsysteme.csv")
suspend fun spielsysteme(): Response<ResponseBody>
@GET("/api/vereinsmeisterschaften")
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
@POST("/api/cms/save-csv")
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
@POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
@Streaming
@GET
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
@POST("/api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("/api/auth/logout")
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
@POST("/api/auth/refresh")
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
@GET("/api/auth/status")
suspend fun authStatus(): Response<AuthStatusResponse>
@POST("/api/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequest): Response<AuthMessageResponse>
@POST("/api/auth/register")
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
@POST("/api/auth/passkeys/authentication-options")
suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody>
@POST("/api/auth/passkeys/login")
suspend fun passkeyLogin(@Body request: RequestBody): Response<LoginResponse>
@GET("/api/auth/passkeys/list")
suspend fun passkeys(): Response<PasskeysResponse>
@POST("/api/auth/passkeys/registration-options")
suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response<ResponseBody>
@POST("/api/auth/passkeys/register")
suspend fun registerPasskey(@Body request: RequestBody): Response<AuthMessageResponse>
@POST("/api/auth/passkeys/remove")
suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response<AuthMessageResponse>
@GET("/api/profile")
suspend fun profile(): Response<ProfileResponse>
@retrofit2.http.PUT("/api/profile")
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
@GET("/api/profile/notifications")
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
@retrofit2.http.PUT("/api/profile/notifications")
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
@POST("/api/profile/push-token")
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
@GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse>
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/members")
suspend fun members(): Response<MembersResponse>
data class MemberSaveRequest(
val id: String? = null,
val firstName: String,
val lastName: String,
val geburtsdatum: String,
val email: String? = null,
val phone: String? = null,
val address: String? = null,
val notes: String? = null,
val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false,
)
data class BulkImportRequest(val members: List<Map<String, String>>)
data class BulkImportResponse(val success: Boolean = false, val summary: Map<String, Int>? = null)
@POST("/api/members")
suspend fun saveMember(@Body request: MemberSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/members")
suspend fun deleteMember(@Body body: Map<String, String>): Response<AuthMessageResponse>
@POST("/api/members/bulk")
suspend fun bulkImportMembers(@Body request: BulkImportRequest): Response<BulkImportResponse>
@POST("/api/members/toggle-mannschaftsspieler")
suspend fun toggleMannschaftsspieler(@Body body: Map<String, String>): Response<Map<String, Any>>
@GET("/api/cms/users/list")
suspend fun cmsUsers(): Response<CmsUsersResponse>
data class UpdateUserRolesRequest(val id: String, val roles: List<String>)
data class UpdateUserActiveRequest(val id: String, val active: Boolean)
@PUT("/api/cms/users/update-roles")
suspend fun updateUserRoles(@Body request: UpdateUserRolesRequest): Response<AuthMessageResponse>
@PUT("/api/cms/users/update-active")
suspend fun updateUserActive(@Body request: UpdateUserActiveRequest): Response<AuthMessageResponse>
@POST("/api/cms/users/resend-invite")
suspend fun resendInvite(@Query("id") id: String): Response<AuthMessageResponse>
@GET("/api/cms/contact-requests")
suspend fun contactRequests(): Response<List<ContactRequestDto>>
data class ContactReplyRequest(val message: String)
@POST("/api/cms/contact-requests/{id}/reply")
suspend fun replyToContactRequest(@Path("id") id: String, @Body request: ContactReplyRequest): Response<de.harheimertc.data.ContactResponse>
@PATCH("/api/cms/contact-requests/{id}/toggle-status")
suspend fun toggleContactRequestStatus(@Path("id") id: String): Response<de.harheimertc.data.ContactResponse>
@GET("/api/newsletter/list")
suspend fun newsletters(): Response<NewsletterListResponse>
@GET("/api/newsletter/groups/list")
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
@POST("/api/newsletter/groups/create")
suspend fun createNewsletterGroup(@Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
@PUT("/api/newsletter/groups/{id}")
suspend fun updateNewsletterGroup(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
@DELETE("/api/newsletter/groups/{id}")
suspend fun deleteNewsletterGroup(@Path("id") id: String): Response<AuthMessageResponse>
@POST("/api/newsletter/create")
suspend fun createNewsletter(@Body request: NewsletterCreateRequest): Response<NewsletterCreateResponse>
@PUT("/api/newsletter/{id}")
suspend fun updateNewsletter(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<NewsletterCreateResponse>
@POST("/api/newsletter/{id}/send")
suspend fun sendNewsletter(@Path("id") id: String): Response<NewsletterSendResponse>
@DELETE("/api/newsletter/{id}")
suspend fun deleteNewsletter(@Path("id") id: String): Response<AuthMessageResponse>
@GET("/api/newsletter/groups/public-list")
suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse>
@POST("/api/newsletter/subscribe")
suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
@POST("/api/newsletter/unsubscribe-by-email")
suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
@GET("/api/newsletter/confirm")
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
@GET("/api/cms/password-reset-diagnostics")
suspend fun passwordResetDiagnostics(
@Query("email") email: String? = null,
@Query("failedOnly") failedOnly: Boolean = true,
): Response<PasswordResetDiagnosticsResponse>
}

View File

@@ -0,0 +1,17 @@
package de.harheimertc.data
import de.harheimertc.repositories.AuthRepository
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
class AuthInterceptor @Inject constructor(private val authRepository: AuthRepository) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val requestBuilder = chain.request().newBuilder()
val token = authRepository.getToken()
if (!token.isNullOrBlank()) {
requestBuilder.addHeader("Authorization", "Bearer $token")
}
return chain.proceed(requestBuilder.build())
}
}

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(
@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

@@ -0,0 +1,7 @@
package de.harheimertc.data
import okhttp3.MediaType.Companion.toMediaType
object MediaTypes {
val json = "application/json; charset=utf-8".toMediaType()
}

View File

@@ -0,0 +1,106 @@
package de.harheimertc.data
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import de.harheimertc.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.Cache
import okhttp3.CacheControl
import okhttp3.OkHttpClient
import okhttp3.JavaNetCookieJar
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton
import java.net.CookieManager
import java.net.CookiePolicy
import java.util.concurrent.TimeUnit
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideHttpCache(@ApplicationContext context: Context): Cache =
Cache(context.cacheDir.resolve("http_cache"), 25L * 1024L * 1024L)
@Provides
@Singleton
fun provideOkHttpClient(
@ApplicationContext context: Context,
authInterceptor: AuthInterceptor,
accessTokenAuthenticator: AccessTokenAuthenticator,
cache: Cache,
): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
val cookies = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
return OkHttpClient.Builder()
.cache(cache)
.cookieJar(JavaNetCookieJar(cookies))
.addInterceptor(authInterceptor)
.addInterceptor { chain ->
val request = chain.request()
if (request.method == "GET" && !hasNetwork(context)) {
val offlineRequest = request.newBuilder()
.cacheControl(CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build())
.build()
chain.proceed(offlineRequest)
} else {
chain.proceed(request)
}
}
.addNetworkInterceptor { chain ->
val response = chain.proceed(chain.request())
val request = response.request
if (request.method == "GET" && request.header("Authorization").isNullOrBlank()) {
response.newBuilder()
.header("Cache-Control", "public, max-age=300")
.build()
} else {
response
}
}
.authenticator(accessTokenAuthenticator)
.addInterceptor(logging)
.build()
}
@Provides
@Singleton
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
val runtimeBase = BuildConfig.API_BASE_URL
android.util.Log.i("NetworkModule", "Retrofit baseUrl runtime=$runtimeBase")
return Retrofit.Builder()
.baseUrl(runtimeBase)
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
private fun hasNetwork(context: Context): Boolean {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
val network = manager.activeNetwork ?: return false
val capabilities = manager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}

View File

@@ -0,0 +1,172 @@
package de.harheimertc.data
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.GeneralSecurityException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SecureOfflineCache @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private val tag = "SecureOfflineCache"
private companion object {
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values"
const val KEY_MEMBERS = "members"
const val KEY_MEMBER_NEWS = "member_news"
const val KEY_CMS_CONFIG = "cms_config"
const val KEY_CMS_USERS = "cms_users"
const val KEY_CONTACT_REQUESTS = "contact_requests"
const val KEY_NEWSLETTERS = "newsletters"
const val KEY_NEWSLETTER_GROUPS = "newsletter_groups"
const val KEY_PASSWORD_RESET_DIAGNOSTICS = "password_reset_diagnostics"
const val TIMESTAMP_SUFFIX = "_ts"
}
private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_offline_cache",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_offline_cache")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_offline_cache",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
throw retryError
}
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
fun putNews(response: NewsResponse) = put(KEY_MEMBER_NEWS, response, NewsResponse::class.java)
fun getNews(maxAgeMillis: Long? = null): NewsResponse? = get(KEY_MEMBER_NEWS, NewsResponse::class.java, maxAgeMillis)
fun putConfig(response: ConfigResponse) = put(KEY_CMS_CONFIG, response, ConfigResponse::class.java)
fun getConfig(maxAgeMillis: Long? = null): ConfigResponse? = get(KEY_CMS_CONFIG, ConfigResponse::class.java, maxAgeMillis)
fun putCmsUsers(response: CmsUsersResponse) = put(KEY_CMS_USERS, response, CmsUsersResponse::class.java)
fun getCmsUsers(maxAgeMillis: Long? = null): CmsUsersResponse? = get(KEY_CMS_USERS, CmsUsersResponse::class.java, maxAgeMillis)
fun putContactRequests(response: List<ContactRequestDto>) {
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
preferences.edit()
.putString(KEY_CONTACT_REQUESTS, json)
.putLong(timestampKey(KEY_CONTACT_REQUESTS), System.currentTimeMillis())
.apply()
}
fun getContactRequests(maxAgeMillis: Long? = null): List<ContactRequestDto>? {
if (isExpired(KEY_CONTACT_REQUESTS, maxAgeMillis)) return null
val json = preferences.getString(KEY_CONTACT_REQUESTS, null) ?: return null
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
}
fun putNewsletters(response: NewsletterListResponse) = put(KEY_NEWSLETTERS, response, NewsletterListResponse::class.java)
fun getNewsletters(maxAgeMillis: Long? = null): NewsletterListResponse? =
get(KEY_NEWSLETTERS, NewsletterListResponse::class.java, maxAgeMillis)
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put(KEY_NEWSLETTER_GROUPS, response, NewsletterGroupsResponse::class.java)
fun getNewsletterGroups(maxAgeMillis: Long? = null): NewsletterGroupsResponse? =
get(KEY_NEWSLETTER_GROUPS, NewsletterGroupsResponse::class.java, maxAgeMillis)
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
put(KEY_PASSWORD_RESET_DIAGNOSTICS, response, PasswordResetDiagnosticsResponse::class.java)
fun getPasswordResetDiagnostics(maxAgeMillis: Long? = null): PasswordResetDiagnosticsResponse? =
get(KEY_PASSWORD_RESET_DIAGNOSTICS, PasswordResetDiagnosticsResponse::class.java, maxAgeMillis)
fun clearCmsProtectedCaches() {
clear(
KEY_CMS_CONFIG,
KEY_CMS_USERS,
KEY_CONTACT_REQUESTS,
KEY_NEWSLETTERS,
KEY_NEWSLETTER_GROUPS,
KEY_PASSWORD_RESET_DIAGNOSTICS,
KEY_MEMBER_NEWS,
KEY_QTTR_VALUES,
)
}
fun clearCmsUsersCache() = clear(KEY_CMS_USERS)
fun clearContactRequestsCache() = clear(KEY_CONTACT_REQUESTS)
fun clearNewslettersCache() = clear(KEY_NEWSLETTERS)
fun clearNewsletterGroupsCache() = clear(KEY_NEWSLETTER_GROUPS)
fun clearPasswordResetDiagnosticsCache() = clear(KEY_PASSWORD_RESET_DIAGNOSTICS)
fun clearCmsConfigCache() = clear(KEY_CMS_CONFIG)
fun clearCmsNewsCache() = clear(KEY_MEMBER_NEWS)
private fun <T> put(key: String, value: T, type: Class<T>) {
val json = moshi.adapter(type).toJson(value)
preferences.edit()
.putString(key, json)
.putLong(timestampKey(key), System.currentTimeMillis())
.apply()
}
private fun <T> get(key: String, type: Class<T>, maxAgeMillis: Long? = null): T? {
if (isExpired(key, maxAgeMillis)) return null
val json = preferences.getString(key, null) ?: return null
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
}
private fun clear(vararg keys: String) {
val editor = preferences.edit()
keys.forEach { key ->
editor.remove(key)
editor.remove(timestampKey(key))
}
editor.apply()
}
private fun isExpired(key: String, maxAgeMillis: Long?): Boolean {
if (maxAgeMillis == null) return false
val savedAt = preferences.getLong(timestampKey(key), 0L)
if (savedAt <= 0L) return true
return (System.currentTimeMillis() - savedAt) > maxAgeMillis
}
private fun timestampKey(key: String): String = key + TIMESTAMP_SUFFIX
}

View File

@@ -0,0 +1,64 @@
package de.harheimertc.data
import com.squareup.moshi.Moshi
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SessionRefresher @Inject constructor(
private val authRepository: AuthRepository,
moshi: Moshi,
) {
private val lock = Any()
private val client = OkHttpClient.Builder().build()
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
refreshAccessTokenBlocking() != null
}
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
val currentToken = authRepository.getToken()
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
return@synchronized currentToken
}
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
?: return@synchronized null
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
val request = Request.Builder()
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
.post(payload.toRequestBody("application/json".toMediaType()))
.build()
try {
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
authRepository.clearSession()
return@synchronized null
}
if (!response.isSuccessful) return@synchronized null
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
?: return@synchronized null
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
?: return@synchronized null
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized null
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
accessToken
}
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,17 @@
package de.harheimertc.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.AuthRepositoryImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
}

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)
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
}
}

View File

@@ -0,0 +1,51 @@
package de.harheimertc.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
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.R
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,
): 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)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(notificationId, notification)
return true
}
}

View File

@@ -0,0 +1,13 @@
package de.harheimertc.repositories
interface AuthRepository {
fun getToken(): String?
fun getRefreshToken(): String?
fun getSessionId(): String?
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
fun clearSession()
// Device binding via Android Keystore (optional enhancement)
fun ensureDeviceKey(): String?
fun getDevicePublicKey(): String?
fun signWithDeviceKey(data: ByteArray): ByteArray?
}

View File

@@ -0,0 +1,101 @@
package de.harheimertc.repositories
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.security.DeviceKeyManager
import java.security.GeneralSecurityException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(
@param:ApplicationContext private val context: Context,
private val deviceKeyManager: DeviceKeyManager,
) : AuthRepository {
private val tag = "AuthRepository"
private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id"
private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_auth")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
throw retryError
}
override fun getToken(): String? = preferences.getString(tokenKey, null)
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
preferences.edit().apply {
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
}.apply()
}
override fun clearSession() {
preferences.edit()
.remove(tokenKey)
.remove(refreshTokenKey)
.remove(sessionIdKey)
.apply()
}
// Keystore / device binding helpers
override fun ensureDeviceKey(): String? = try {
deviceKeyManager.ensureKeyPair()
} catch (e: Exception) {
null
}
override fun getDevicePublicKey(): String? = try {
deviceKeyManager.getPublicKeyBase64()
} catch (e: Exception) {
null
}
override fun signWithDeviceKey(data: ByteArray): ByteArray? = try {
deviceKeyManager.sign(data)
} catch (e: Exception) {
null
}
}

View File

@@ -0,0 +1,319 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject
class CmsRepository @Inject constructor(
private val api: ApiService,
private val cache: SecureOfflineCache,
) {
private companion object {
const val CMS_CACHE_MAX_AGE_MS = 24L * 60L * 60L * 1000L
const val PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS = 6L * 60L * 60L * 1000L
}
suspend fun config(): Result<ConfigResponse> =
fetchEncryptedFallback(
load = {
val response = api.config()
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putConfig,
cached = { cache.getConfig(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Konfiguration konnte nicht geladen werden.",
)
suspend fun saveConfig(config: ConfigResponse): Result<ConfigResponse> = runCatching {
val response = api.updateConfig(config)
if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.")
val saved = response.body() ?: error("Leere Antwort vom Server.")
cache.putConfig(saved)
saved
}
suspend fun vereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 6) return@mapNotNull null
MeisterschaftResult(
year = values[0],
category = values[1],
rank = values[2],
playerOne = values[3],
playerTwo = values[4],
note = values[5],
imageOne = values.getOrElse(6) { "" },
imageTwo = values.getOrElse(7) { "" },
)
}
}
suspend fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>): Result<SaveCsvResponse> = runCatching {
val csvHeader = "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2"
val csvRows = results.map { result ->
listOf(
result.year,
result.category,
result.rank,
result.playerOne,
result.playerTwo,
result.note,
result.imageOne,
result.imageTwo,
).joinToString(",") { value -> "\"${value.replace("\"", "\"\"")}\"" }
}
val response = api.saveCsv(
SaveCsvRequest(
filename = "vereinsmeisterschaften.csv",
content = listOf(csvHeader).plus(csvRows).joinToString("\n"),
),
)
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht gespeichert werden.")
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun users(): Result<CmsUsersResponse> =
fetchEncryptedFallback(
load = {
val response = api.cmsUsers()
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putCmsUsers,
cached = { cache.getCmsUsers(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Benutzer konnten nicht geladen werden.",
)
suspend fun updateUserRoles(id: String, roles: List<String>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val req = de.harheimertc.data.ApiService.UpdateUserRolesRequest(id, roles)
val response = api.updateUserRoles(req)
if (!response.isSuccessful) error("Benutzerrollen konnten nicht aktualisiert werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun updateUserActive(id: String, active: Boolean): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val req = de.harheimertc.data.ApiService.UpdateUserActiveRequest(id, active)
val response = api.updateUserActive(req)
if (!response.isSuccessful) error("Benutzerstatus konnte nicht aktualisiert werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun resendInvite(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.resendInvite(id)
if (!response.isSuccessful) error("Einladung konnte nicht erneut gesendet werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
fetchEncryptedFallback(
load = {
val response = api.contactRequests()
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
response.body() ?: emptyList()
},
save = cache::putContactRequests,
cached = { cache.getContactRequests(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
)
suspend fun replyToContactRequest(id: String, message: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
val req = ApiService.ContactReplyRequest(message)
val response = api.replyToContactRequest(id, req)
if (!response.isSuccessful) error("Antwort konnte nicht gesendet werden.")
cache.clearContactRequestsCache()
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
}
suspend fun toggleContactRequestStatus(id: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
val response = api.toggleContactRequestStatus(id)
if (!response.isSuccessful) error("Status konnte nicht geändert werden.")
cache.clearContactRequestsCache()
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
}
suspend fun newsletters(): Result<NewsletterListResponse> =
fetchEncryptedFallback(
load = {
val response = api.newsletters()
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putNewsletters,
cached = { cache.getNewsletters(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Newsletter konnten nicht geladen werden.",
)
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> =
fetchEncryptedFallback(
load = {
val response = api.newsletterGroups()
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putNewsletterGroups,
cached = { cache.getNewsletterGroups(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.",
)
suspend fun passwordResetDiagnostics(
email: String? = null,
failedOnly: Boolean = true,
): Result<PasswordResetDiagnosticsResponse> {
val normalizedEmail = email?.trim().orEmpty()
val canUseSharedCache = normalizedEmail.isBlank() && failedOnly
return fetchEncryptedFallback(
load = {
val response = api.passwordResetDiagnostics(
email = normalizedEmail.takeIf { it.isNotBlank() },
failedOnly = failedOnly,
)
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = { response ->
if (canUseSharedCache) cache.putPasswordResetDiagnostics(response)
},
cached = {
if (canUseSharedCache) {
cache.getPasswordResetDiagnostics(PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS)
} else {
null
}
},
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
)
}
suspend fun news(): Result<de.harheimertc.data.NewsResponse> =
fetchEncryptedFallback(
load = {
val response = api.memberNews()
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
response.body() ?: de.harheimertc.data.NewsResponse()
},
save = cache::putNews,
cached = { cache.getNews(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "News konnten nicht geladen werden.",
)
suspend fun saveNews(request: de.harheimertc.data.NewsSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.saveNews(request)
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
cache.clearCmsNewsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.createNewsletter(request)
if (!response.isSuccessful) error("Newsletter konnte nicht erstellt werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletter(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.updateNewsletter(id, patch)
if (!response.isSuccessful) error("Newsletter konnte nicht aktualisiert werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun sendNewsletter(id: String): Result<de.harheimertc.data.NewsletterSendResponse> = runCatching {
val response = api.sendNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht versendet werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterSendResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletter(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht gelöscht werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun createNewsletterGroup(payload: Map<String, @JvmSuppressWildcards Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
// use generic POST via Retrofit? build request through create endpoint
val response = api.createNewsletterGroup(payload)
if (!response.isSuccessful) error("Gruppe konnte nicht erstellt werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletterGroup(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.updateNewsletterGroup(id, patch)
if (!response.isSuccessful) error("Gruppe konnte nicht aktualisiert werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletterGroup(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletterGroup(id)
if (!response.isSuccessful) error("Gruppe konnte nicht gelöscht werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
private suspend fun <T> fetchEncryptedFallback(
load: suspend () -> T,
save: (T) -> Unit,
cached: () -> T?,
fallbackMessage: String,
): Result<T> = runCatching {
runCatching { load() }
.onSuccess(save)
.getOrElse { original ->
cached() ?: throw IllegalStateException(fallbackMessage, original)
}
}
}
private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
private fun parseCsvLine(line: String): List<String> {
val values = mutableListOf<String>()
val value = StringBuilder()
var quoted = false
var index = 0
while (index < line.length) {
when (val char = line[index]) {
'"' -> {
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
value.append('"')
index++
} else {
quoted = !quoted
}
}
',' -> if (quoted) value.append(char) else {
values += value.toString().trim()
value.clear()
}
else -> value.append(char)
}
index++
}
values += value.toString().trim()
return values
}

View File

@@ -0,0 +1,13 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ContactRequest
import de.harheimertc.data.ContactResponse
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContactRepository @Inject constructor(private val api: ApiService) {
suspend fun sendContact(req: ContactRequest): Response<ContactResponse> = api.postContact(req)
}

View File

@@ -0,0 +1,124 @@
package de.harheimertc.repositories
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.GalleryImageDto
import de.harheimertc.data.GalleryPaginationDto
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GalleryRepository @Inject constructor(
private val api: ApiService,
@param:ApplicationContext private val context: Context,
) {
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
retryOnNetworkFailure {
val response = api.galerieList(page = 1, perPage = 1)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.images.orEmpty().isNotEmpty()
}
}
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
return runCatching {
retryOnNetworkFailure {
val resp = api.galerieList(page = page, perPage = perPage)
if (resp.isSuccessful) {
val body = resp.body()
GalleryPage(
images = body?.images.orEmpty().map { it.toGalleryImage() },
pagination = body?.pagination ?: GalleryPaginationDto(),
)
} else {
error("HTTP ${resp.code()}")
}
}
}
}
suspend fun uploadImage(uri: Uri, title: String, description: String, isPublic: Boolean): Result<Unit> = runCatching {
val titleValue = title.trim()
require(titleValue.isNotBlank()) { "Bitte einen Titel eintragen." }
val uploadFile = prepareCompressedUploadFile(uri)
val mediaType = "image/jpeg".toMediaType()
val imageBody = uploadFile.asRequestBody(mediaType)
val imagePart = MultipartBody.Part.createFormData("image", uploadFile.name, imageBody)
val textType = "text/plain".toMediaType()
val response = api.uploadGalleryImage(
image = imagePart,
title = titleValue.toRequestBody(textType),
description = description.trim().toRequestBody(textType),
isPublic = isPublic.toString().toRequestBody(textType),
)
uploadFile.delete()
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body()
if (body?.success == false) error(body.message ?: "Fehler beim Hochladen des Bildes")
}
private fun GalleryImageDto.toGalleryImage(): GalleryImage {
val base = BuildConfig.API_BASE_URL.trimEnd('/')
return GalleryImage(
id = id,
title = title,
description = description,
isPublic = isPublic,
uploadedAt = uploadedAt,
previewUrl = "$base/api/media/galerie/$id?preview=true",
imageUrl = "$base/api/media/galerie/$id",
)
}
private fun prepareCompressedUploadFile(uri: Uri): File {
val inputBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Bilddatei konnte nicht gelesen werden.")
val original = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)
?: error("Bilddatei konnte nicht verarbeitet werden.")
val scaled = original.scaleInside(maxSize = 2000)
val file = File(context.cacheDir, "gallery_upload_${System.currentTimeMillis()}.jpg")
FileOutputStream(file).use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
if (scaled !== original) scaled.recycle()
original.recycle()
return file
}
private fun Bitmap.scaleInside(maxSize: Int): Bitmap {
val largestSide = maxOf(width, height)
if (largestSide <= maxSize) return this
val scale = maxSize.toFloat() / largestSide.toFloat()
val nextWidth = (width * scale).toInt().coerceAtLeast(1)
val nextHeight = (height * scale).toInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(this, nextWidth, nextHeight, true)
}
}
data class GalleryImage(
val id: String,
val title: String,
val description: String,
val isPublic: Boolean,
val uploadedAt: String?,
val previewUrl: String,
val imageUrl: String,
)
data class GalleryPage(
val images: List<GalleryImage>,
val pagination: GalleryPaginationDto,
)

View File

@@ -0,0 +1,80 @@
package de.harheimertc.repositories
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.HomepageSectionDto
import java.security.GeneralSecurityException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomeLayoutPreferences @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private val tag = "HomeLayoutPreferences"
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_home_layout",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_home_layout")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_home_layout",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
throw retryError
}
fun getSections(): List<HomepageSectionDto>? {
val json = preferences.getString(HOME_SECTIONS_KEY, null) ?: return null
return runCatching { sectionListAdapter.fromJson(json) }.getOrNull()
}
fun setSections(sections: List<HomepageSectionDto>) {
val json = sectionListAdapter.toJson(sections)
preferences.edit().putString(HOME_SECTIONS_KEY, json).apply()
}
fun clearSections() {
preferences.edit().remove(HOME_SECTIONS_KEY).apply()
}
private companion object {
const val HOME_SECTIONS_KEY = "home_sections"
}
}

View File

@@ -0,0 +1,260 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.HeroImageVariantDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import io.sentry.Sentry
import kotlin.random.Random
import javax.inject.Inject
import javax.inject.Singleton
data class HomeData(
val termine: List<TerminDto>,
val spiele: List<SpielDto>,
val spielplanSeasons: List<SeasonDto>,
val selectedSpielplanSeason: String?,
val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>,
val heroImageUrl: String? = null,
val diagnostics: List<String> = emptyList(),
)
@Singleton
class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val diagnostics = mutableListOf<String>()
val termine = runCatching {
retryOnNetworkFailure {
val response = api.termine()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.termine.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.termine", error)
if (diagnostics.none { it.contains("GET /api/termine") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val spielplanResponse = runCatching {
retryOnNetworkFailure {
val response = api.spielplan()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.spielplan", error)
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
val spiele = spielplanResponse?.data.orEmpty()
val news = runCatching {
retryOnNetworkFailure {
val response = api.publicNews()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.news.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.news", error)
if (diagnostics.none { it.contains("GET /api/news-public") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val homepageSections = runCatching {
retryOnNetworkFailure {
val response = api.config()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.homepage?.sections.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.config", error)
if (diagnostics.none { it.contains("GET /api/config") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val heroImageUrl = runCatching {
retryOnNetworkFailure {
val response = api.heroImages()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
}
val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants)
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.heroImages", error)
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
HomeData(
termine = termine,
spiele = spiele,
spielplanSeasons = spielplanResponse?.seasons.orEmpty(),
selectedSpielplanSeason = spielplanResponse?.season,
news = news,
homepageSections = homepageSections,
heroImageUrl = heroImageUrl,
diagnostics = diagnostics,
)
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchHomeData")
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
}
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchSpielplanForSeason")
scope.setExtra("season", season)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun captureLoadIssue(operation: String, error: Throwable) {
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", operation)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun buildDiagnostic(
endpoint: String,
requestPayload: String,
httpCode: Int?,
responseBody: String?,
throwable: Throwable?,
): String {
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
return buildString {
append("Endpoint: ").append(endpoint).append('\n')
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
append("Request: ").append(requestPayload).append('\n')
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
append("Response: ").append(responsePreview).append('\n')
append("Throwable: ").append(throwableInfo)
}
}
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
if (variants.isEmpty()) return null
val valid = variants.filter { it.fallback.isNotBlank() }
if (valid.isEmpty()) return null
val selected = valid[Random.nextInt(valid.size)]
return toAbsoluteUrl(selected.fallback)
}
private fun toAbsoluteUrl(pathOrUrl: String): String {
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
return pathOrUrl
}
val base = BuildConfig.API_BASE_URL.trimEnd('/')
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
return "$base$path"
}
}

View File

@@ -0,0 +1,125 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import io.sentry.Sentry
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LoginRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val endpoint = "api/auth/login"
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
if (!response.isSuccessful) {
val body = response.errorBody()?.string().orEmpty()
val serverMessage = extractServerMessage(body)
val fallback = when (response.code()) {
401 -> "Ungueltige Anmeldedaten"
403 -> "Konto nicht freigeschaltet"
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
else -> "Anmeldung fehlgeschlagen"
}
val diagnostic = buildString {
append("\n\nDiagnose:\n")
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
append("Request: ").append(requestPreview).append('\n')
append("HTTP: ").append(response.code()).append('\n')
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
append("Server message: ").append(serverMessage ?: "none")
}
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
}
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "login")
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun logout(): Result<Unit> = runCatching {
try {
api.logout(LogoutRequest(authRepository.getRefreshToken()))
} finally {
authRepository.clearSession()
}
}
suspend fun status(): Result<AuthStatusResponse> = runCatching {
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
return@runCatching AuthStatusResponse()
}
var response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse()
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "status")
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
response.body() ?: error("Leere Antwort")
}
}
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.register(request)
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
}
}
private fun extractServerMessage(raw: String): String? {
if (raw.isBlank()) return null
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
}
private fun maskEmail(rawEmail: String): String {
val email = rawEmail.trim()
if (!email.contains('@')) return "hidden"
val local = email.substringBefore('@')
val domain = email.substringAfter('@')
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
return "$localMasked@$domain"
}
}

View File

@@ -0,0 +1,89 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.MannschaftenSeasonsResponse
import de.harheimertc.data.SeasonDto
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
data class Mannschaft(
val mannschaft: String,
val liga: String,
val staffelleiter: String,
val telefon: String,
val heimspieltag: String,
val spielsystem: String,
val mannschaftsfuehrer: String,
val spieler: List<String>,
val informationenLink: String,
val letzteAktualisierung: String,
) {
val slug: String
get() = mannschaft.lowercase(Locale.GERMANY).replace(Regex("\\s+"), "-")
}
@Singleton
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty())
}
}
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: error("Saisons konnten nicht geladen werden.")
}
}
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
.filter(String::isNotBlank)
.drop(1)
.mapNotNull { row ->
val fields = parseCsvRow(row)
if (fields.size < 10 || fields[0].isBlank()) return@mapNotNull null
Mannschaft(
mannschaft = fields[0],
liga = fields[1],
staffelleiter = fields[2],
telefon = fields[3],
heimspieltag = fields[4],
spielsystem = fields[5],
mannschaftsfuehrer = fields[6],
spieler = fields[7].split(';').map(String::trim).filter(String::isNotBlank),
informationenLink = fields[8],
letzteAktualisierung = fields[9],
)
}
.toList()
private fun parseCsvRow(row: String): List<String> {
val values = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
var index = 0
while (index < row.length) {
val character = row[index]
when {
character == '"' && inQuotes && row.getOrNull(index + 1) == '"' -> {
current.append('"')
index++
}
character == '"' -> inQuotes = !inQuotes
character == ',' && !inQuotes -> {
values += current.toString().trim()
current.clear()
}
else -> current.append(character)
}
index++
}
values += current.toString().trim()
return values
}
}

View File

@@ -0,0 +1,121 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.MembersResponse
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.QttrValuesResponse
import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject
class MemberAreaRepository @Inject constructor(
private val api: ApiService,
private val cache: SecureOfflineCache,
) {
suspend fun birthdays(): Result<BirthdaysResponse> =
fetchEncryptedFallback(
load = {
val response = api.birthdays()
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putBirthdays,
cached = cache::getBirthdays,
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
)
suspend fun qttrValues(): Result<QttrValuesResponse> =
fetchEncryptedFallback(
load = {
val response = api.qttrValues()
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putQttrValues,
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
)
suspend fun members(): Result<MembersResponse> =
fetchEncryptedFallback(
load = {
val response = api.members()
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putMembers,
cached = cache::getMembers,
fallbackMessage = "Mitglieder konnten nicht geladen werden.",
)
suspend fun news(): Result<NewsResponse> =
fetchEncryptedFallback(
load = {
val response = api.memberNews()
if (!response.isSuccessful) {
try {
val body = response.errorBody()?.string()
android.util.Log.w("MemberAreaRepository", "memberNews failed: code=${response.code()} body=${body?.take(500)}")
} catch (e: Exception) {
// ignore
}
error("News konnten nicht geladen werden.")
}
response.body() ?: run {
android.util.Log.w("MemberAreaRepository", "memberNews: successful but empty body (null)")
NewsResponse(success = false, news = emptyList())
}
},
save = cache::putNews,
cached = cache::getNews,
fallbackMessage = "News konnten nicht geladen werden.",
)
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
val response = api.saveNews(request)
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
}
suspend fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.saveMember(request)
if (!response.isSuccessful) error("Mitglied konnte nicht gespeichert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteMember(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteMember(mapOf("id" to id))
if (!response.isSuccessful) error("Mitglied konnte nicht gelöscht werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun bulkImport(members: List<Map<String, String>>): Result<de.harheimertc.data.ApiService.BulkImportResponse> = runCatching {
val response = api.bulkImportMembers(de.harheimertc.data.ApiService.BulkImportRequest(members))
if (!response.isSuccessful) error("Bulk-Import fehlgeschlagen")
response.body() ?: de.harheimertc.data.ApiService.BulkImportResponse(success = false)
}
suspend fun toggleMannschaftsspieler(memberId: String): Result<Map<String, Any>> = runCatching {
val response = api.toggleMannschaftsspieler(mapOf("memberId" to memberId))
if (!response.isSuccessful) error("Status konnte nicht umgeschaltet werden.")
response.body() ?: emptyMap()
}
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
}
private suspend fun <T> fetchEncryptedFallback(
load: suspend () -> T,
save: (T) -> Unit,
cached: () -> T?,
fallbackMessage: String,
): Result<T> = runCatching {
runCatching { load() }
.onSuccess(save)
.getOrElse { original ->
cached() ?: throw IllegalStateException(fallbackMessage, original)
}
}
}

View File

@@ -0,0 +1,38 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.ApiService
import de.harheimertc.data.MembershipRequest
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class MembershipDocument(val message: String, val uri: String)
@Singleton
class MembershipRepository @Inject constructor(
private val api: ApiService,
@param:ApplicationContext private val context: Context,
) {
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
val response = api.generateMembershipPdf(request)
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
val documentResponse = api.downloadMembershipPdf(downloadUrl)
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
val file = File(directory, "beitrittserklaerung.pdf")
documentResponse.body()?.byteStream()?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: error("Leere PDF-Antwort")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
MembershipDocument(
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
uri = uri.toString(),
)
}
}

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

@@ -0,0 +1,37 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterSubscriptionRequest
import javax.inject.Inject
class NewsletterRepository @Inject constructor(private val api: ApiService) {
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.publicNewsletterGroups()
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
}
}
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty()))
if (!response.isSuccessful) error("Newsletter-Anmeldung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Eine Bestätigungsmail wurde versendet.")
}
suspend fun unsubscribe(groupId: String, email: String): Result<AuthMessageResponse> = runCatching {
val response = api.unsubscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim()))
if (!response.isSuccessful) error("Newsletter-Abmeldung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Sie wurden abgemeldet.")
}
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.confirmNewsletter(token)
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
}
}
}

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

@@ -0,0 +1,120 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialCancellationException
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.MediaTypes
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
import de.harheimertc.data.PasskeysResponse
import de.harheimertc.data.RemovePasskeyRequest
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PasskeyRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
) {
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
retryOnNetworkFailure {
val optionsResponse = api.passkeyAuthenticationOptions(
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
)
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.getCredential(
context = context,
request = GetCredentialRequest(
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
),
)
val credential = credentialResponse.credential as? PublicKeyCredential
?: error("Der ausgewählte Zugang ist kein Passkey.")
val response = api.passkeyLogin(
JSONObject()
.put("credential", JSONObject(credential.authenticationResponseJson))
.put("client", "android")
.put("deviceName", "Harheimer TC Android-App")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
suspend fun list(): Result<PasskeysResponse> = runCatching {
retryOnNetworkFailure {
val response = api.passkeys()
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
}
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.createCredential(
context = context,
request = CreatePublicKeyCredentialRequest(optionsJson),
) as? CreatePublicKeyCredentialResponse
?: error("Der erstellte Zugang ist kein Passkey.")
val response = api.registerPasskey(
JSONObject()
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
.put("name", name)
.put("client", "android")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
response.body() ?: error("Leere Antwort")
}
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
val response = api.removePasskey(RemovePasskeyRequest(credentialId))
if (!response.isSuccessful) error("Passkey konnte nicht entfernt werden.")
response.body() ?: error("Leere Antwort")
}
private fun String.extractJsonObject(key: String): String? {
val root = JSONObject(this)
return root.optJSONObject(key)?.toString()
}
private fun <T> Result<T>.recoverCredentialCancellation(message: String): Result<T> =
recoverCatching { error ->
when (error) {
is GetCredentialCancellationException,
is CreateCredentialCancellationException -> throw IllegalStateException(message)
else -> throw error
}
}
}

View File

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

View File

@@ -0,0 +1,167 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.LinkItemDto
import de.harheimertc.data.LinkSectionDto
import javax.inject.Inject
import javax.inject.Singleton
data class Spielsystem(
val name: String,
val description: String,
val teamSize: String,
val category: String,
val sequence: String,
val gameCount: String,
val features: String,
)
data class MeisterschaftResult(
val year: String,
val category: String,
val rank: String,
val playerOne: String,
val playerTwo: String,
val note: String,
val imageOne: String,
val imageTwo: String,
)
@Singleton
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
retryOnNetworkFailure {
val response = api.config()
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
}
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
retryOnNetworkFailure {
val response = api.spielsysteme()
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 8) return@mapNotNull null
Spielsystem(
name = values[0],
description = values[1],
teamSize = values[2],
category = values[3],
sequence = values[5],
gameCount = values[6],
features = values[7],
)
}
}
}
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
retryOnNetworkFailure {
val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 6) return@mapNotNull null
MeisterschaftResult(
year = values[0],
category = values[1],
rank = values[2],
playerOne = values[3],
playerTwo = values[4],
note = values[5],
imageOne = values.getOrElse(6) { "" },
imageTwo = values.getOrElse(7) { "" },
)
}
}
}
}
private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
private fun parseCsvLine(line: String): List<String> {
val values = mutableListOf<String>()
val value = StringBuilder()
var quoted = false
var index = 0
while (index < line.length) {
when (val char = line[index]) {
'"' -> {
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
value.append('"')
index++
} else {
quoted = !quoted
}
}
',' -> if (quoted) value.append(char) else {
values += value.toString().trim()
value.clear()
}
else -> value.append(char)
}
index++
}
values += value.toString().trim()
return values
}
fun ConfigResponse.linkSections(): List<LinkSectionDto> =
seiten.linksStructured.filter { it.title.isNotBlank() && it.items.isNotEmpty() }
.ifEmpty {
parseLinkSections(seiten.links).ifEmpty { defaultLinkSections }
}
private fun parseLinkSections(html: String): List<LinkSectionDto> {
if (html.isBlank()) return emptyList()
val sectionRegex = Regex("""<h2[^>]*>(.*?)</h2>(.*?)(?=<h2[^>]*>|$)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
val itemRegex = Regex("""<li[^>]*>(.*?)</li>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
val anchorRegex = Regex("""<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
return sectionRegex.findAll(html).mapNotNull { section ->
val title = stripHtml(section.groupValues[1])
val items = itemRegex.findAll(section.groupValues[2]).mapNotNull { item ->
val match = anchorRegex.find(item.groupValues[1]) ?: return@mapNotNull null
LinkItemDto(
href = match.groupValues[1].trim(),
label = stripHtml(match.groupValues[2]),
description = stripHtml(item.groupValues[1].replace(match.value, "")),
)
}.toList()
title.takeIf { it.isNotBlank() && items.isNotEmpty() }?.let { LinkSectionDto(it, items) }
}.toList()
}
private fun stripHtml(html: String): String = html
.replace(Regex("<[^>]*>"), "")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
.replace(Regex("\\s+"), " ")
.trim()
private val defaultLinkSections = listOf(
LinkSectionDto("Ergebnisse & Portale", listOf(
LinkItemDto("MyTischtennis.de", "http://www.mytischtennis.de/public/home", "(offizielle QTTR-Werte)"),
LinkItemDto("Click-tt Ergebnisse", "http://httv.click-tt.de/", "(offizieller Ergebnisdienst HTTV)"),
LinkItemDto("Tischtennis Pur", "https://www.tischtennis-pur.de/", "(Informationen, Blogs und Tipps)"),
LinkItemDto("Liveticker 2. und 3. TT-Bundesliga", "https://ticker.tt-news.com/"),
)),
LinkSectionDto("Verbände", listOf(
LinkItemDto("Hessischer Tischtennisverband (HTTV)", "http://www.httv.de/"),
LinkItemDto("Deutscher Tischtennisbund (DTTB)", "http://www.tischtennis.de/aktuelles/"),
LinkItemDto("European Table Tennis Union (ETTU)", "http://www.ettu.org/"),
LinkItemDto("International Table Tennis Federation (ITTF)", "https://www.ittf.com/"),
)),
LinkSectionDto("Regionale Links", listOf(
LinkItemDto("Stadt Frankfurt", "http://www.frankfurt.de/"),
LinkItemDto("Vereinsring Harheim", "http://www.harheim.com/"),
)),
LinkSectionDto("Partner & Vereine", listOf(
LinkItemDto("TTC OE Bad Homburg", "http://www.ttcoe.de/"),
LinkItemDto("SpVgg Steinkirchen e.V.", "https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis"),
LinkItemDto("Ergebnisse SpVgg Steinkirchen", "https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/"),
)),
)

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

@@ -0,0 +1,30 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TeamTableResponse
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SpielplanRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
body
}
}
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplanTable(team, season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
body
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
package de.harheimertc.security
import android.content.Context
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceKeyManager @Inject constructor(@param:ApplicationContext private val context: Context) {
private val alias = "harheimertc_device_key"
private val keyStore: KeyStore by lazy {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
}
fun ensureKeyPair(): String? {
try {
if (!keyStore.containsAlias(alias)) {
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
val specBuilder = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
)
.setDigests(KeyProperties.DIGEST_SHA256)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setUserAuthenticationRequired(false)
// For older APIs, KeyGenParameterSpec.Builder methods exist from API 23+
kpg.initialize(specBuilder.build())
kpg.generateKeyPair()
}
val pub = keyStore.getCertificate(alias).publicKey.encoded
return Base64.encodeToString(pub, Base64.NO_WRAP)
} catch (e: Exception) {
return null
}
}
fun getPublicKeyBase64(): String? {
return try {
if (!keyStore.containsAlias(alias)) return null
val pub = keyStore.getCertificate(alias).publicKey.encoded
Base64.encodeToString(pub, Base64.NO_WRAP)
} catch (e: Exception) {
null
}
}
fun sign(data: ByteArray): ByteArray? {
return try {
val privateKey = keyStore.getKey(alias, null) as? java.security.PrivateKey ?: return null
val sig = Signature.getInstance("SHA256withECDSA")
sig.initSign(privateKey)
sig.update(data)
sig.sign()
} catch (e: Exception) {
null
}
}
fun deleteKey() {
try {
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
} catch (_: Exception) {
}
}
}

View File

@@ -0,0 +1,498 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.harheimertc.BuildConfig
import de.harheimertc.R
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
private enum class MenuSection {
VEREIN,
MANNSCHAFTEN,
TRAINING,
NEWSLETTER,
INTERN,
CMS,
}
private data class MenuTarget(val label: String, val route: String)
private const val LOGOUT_ROUTE = "__logout__"
@Composable
fun AppNavigationHeader(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit = {},
webTabletNavigation: Boolean = false,
navigationState: NavigationUiState = NavigationUiState(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Brush.horizontalGradient(listOf(Accent900, Primary900, Accent900)))
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
} else {
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
}
}
}
@Composable
private fun CompactNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState = NavigationUiState(),
) {
val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value
val subItems = submenu(section, navigationState)
val mainScroll = rememberScrollState()
val subScroll = rememberScrollState()
BrandRow(
loggedIn = navigationState.loggedIn,
onLogin = { onNavigate(Destinations.Login.route) },
onLogout = onLogout,
)
ScrollableMenuRow(scrollState = mainScroll) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
if (navigationState.canAccessCms) {
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
}
} else {
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) {
if (item.route == LOGOUT_ROUTE) {
onLogout()
} else {
onNavigate(item.route)
}
}
}
}
}
@Composable
private fun CompactSectionLink(
label: String,
section: MenuSection,
activeSection: MenuSection?,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface(
color = if (activeSection == section) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onSelect() },
) {
Text(
label,
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@Composable
private fun WebTabletNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState,
) {
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = sectionOverride.value ?: menuSection(selectedRoute)
val subScroll = rememberScrollState()
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.width(16.dp))
Row(
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Home.route)
})
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.VereinAbout.route)
})
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
sectionOverride.value = MenuSection.MANNSCHAFTEN
onNavigate(Destinations.Mannschaften.route)
})
MainLink("Training", section == MenuSection.TRAINING, onClick = {
sectionOverride.value = MenuSection.TRAINING
onNavigate(Destinations.Training.route)
})
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Termine.route)
})
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.Gallery.route)
})
}
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
sectionOverride.value = MenuSection.NEWSLETTER
onNavigate(Destinations.NewsletterSubscribe.route)
})
if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = {
sectionOverride.value = MenuSection.INTERN
})
if (navigationState.canAccessCms) {
MainLink("CMS", section == MenuSection.CMS, onClick = {
sectionOverride.value = MenuSection.CMS
onNavigate(Destinations.Cms.route)
})
}
}
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Contact.route)
})
}
Spacer(Modifier.width(12.dp))
if (navigationState.loggedIn) {
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
} else {
TextButton(onClick = {
sectionOverride.value = null
onNavigate(Destinations.Login.route)
}) { Text("Login", color = Color.White) }
}
}
val subItems = submenu(section, navigationState)
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) {
if (item.route == LOGOUT_ROUTE) {
onLogout()
} else {
onNavigate(item.route)
}
}
}
}
}
@Composable
private fun BrandRow(
loggedIn: Boolean,
onLogin: () -> Unit,
onLogout: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.weight(1f))
if (loggedIn) {
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
} else {
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
}
}
}
@Composable
private fun Brand() {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.harheimer_tc_logo),
contentDescription = "Harheimer TC Logo",
modifier = Modifier.size(42.dp),
)
Spacer(Modifier.width(10.dp))
Text("Harheimer ", color = Color.White, style = MaterialTheme.typography.titleLarge)
Text("TC", color = Color(0xFFF87171), style = MaterialTheme.typography.titleLarge)
if (BuildConfig.ENVIRONMENT_NAME.isNotBlank()) {
Text(
BuildConfig.ENVIRONMENT_NAME,
color = Color.White,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.padding(start = 8.dp)
.background(Primary600, RoundedCornerShape(5.dp))
.padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}
}
@Composable
private fun MainLink(
label: String,
selected: Boolean,
primary: Boolean = false,
onClick: () -> Unit,
) {
Surface(
color = if (selected || primary) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
maxLines = 1,
)
}
}
@Composable
private fun CompactLink(
label: String,
route: String,
selectedRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
beforeNavigate: () -> Unit = {},
) {
Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable {
beforeNavigate()
onNavigate(route)
},
) {
Text(
label,
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@Composable
private fun ScrollableMenuRow(
scrollState: ScrollState,
topPadding: Dp = 0.dp,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (scrollState.canScrollBackward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
Row(
modifier = Modifier
.weight(1f)
.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
Text(
if (scrollState.canScrollForward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
}
}
@Composable
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
color = if (selected) Primary600 else Color.Transparent,
shape = RoundedCornerShape(5.dp),
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = if (selected) Color.White else Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
maxLines = 1,
)
}
}
private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.VereinAbout.route,
Destinations.Vorstand.route,
Destinations.Geschichte.route,
Destinations.Satzung.route,
Destinations.Vereinsmeisterschaften.route,
Destinations.Links.route,
Destinations.Impressum.route,
Destinations.Gallery.route -> MenuSection.VEREIN
Destinations.Mannschaften.route,
Destinations.Spielplan.route,
Destinations.Spielsysteme.route -> MenuSection.MANNSCHAFTEN
Destinations.Training.route,
Destinations.Trainer.route,
Destinations.Anfaenger.route,
Destinations.Regeln.route -> MenuSection.TRAINING
Destinations.NewsletterSubscribe.route,
Destinations.NewsletterUnsubscribe.route,
Destinations.NewsletterConfirm.route,
Destinations.NewsletterConfirmed.route,
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.Qttr.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.NotificationSettings.route,
Destinations.MemberApi.route -> MenuSection.INTERN
Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route,
Destinations.CmsSportbetrieb.route,
Destinations.CmsMitgliederverwaltung.route,
Destinations.CmsNewsletter.route,
Destinations.CmsContactRequests.route,
Destinations.CmsEinstellungen.route,
Destinations.CmsBenutzer.route,
Destinations.CmsPasswordResetDiagnostics.route,
Destinations.Cms.route -> MenuSection.CMS
else -> null
}.let { section ->
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
}
private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuTarget> = when (section) {
MenuSection.VEREIN -> listOf(
MenuTarget("Über uns", Destinations.VereinAbout.route),
MenuTarget("Vorstand", Destinations.Vorstand.route),
MenuTarget("Geschichte", Destinations.Geschichte.route),
MenuTarget("Satzung", Destinations.Satzung.route),
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
MenuTarget("Galerie", Destinations.Gallery.route),
MenuTarget("Links", Destinations.Links.route),
MenuTarget("Impressum", Destinations.Impressum.route),
)
MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route),
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
MenuTarget("Spielpläne", Destinations.Spielplan.route),
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
)
MenuSection.TRAINING -> listOf(
MenuTarget("Trainingszeiten", Destinations.Training.route),
MenuTarget("Trainer", Destinations.Trainer.route),
MenuTarget("Anfänger", Destinations.Anfaenger.route),
MenuTarget("TT-Regeln", Destinations.Regeln.route),
)
MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
)
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
}
MenuSection.CMS -> buildList {
if (state.canAccessFullCms) {
add(MenuTarget("Übersicht", Destinations.Cms.route))
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
}
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
}
null -> emptyList()
}

View File

@@ -0,0 +1,56 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.VisualTransformation
@Composable
fun ValidatedTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier.fillMaxWidth(),
error: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
singleLine: Boolean = true,
minLines: Int = 1,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
isError = error != null,
supportingText = error?.let { { Text(it) } },
keyboardOptions = keyboardOptions,
visualTransformation = visualTransformation,
singleLine = singleLine,
minLines = minLines,
modifier = modifier,
)
}
@Composable
fun FormMessages(error: String?, message: String?) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
message?.let { Text(it, color = Color(0xFF166534)) }
}
internal fun isValidEmail(value: String): Boolean {
val trimmed = value.trim()
return trimmed.length in 5..254 &&
trimmed.count { it == '@' } == 1 &&
trimmed.substringBefore('@').isNotBlank() &&
trimmed.substringAfter('@').contains('.') &&
!trimmed.any(Char::isWhitespace)
}
internal fun isValidIsoDate(value: String): Boolean =
value.trim().matches(Regex("\\d{4}-\\d{2}-\\d{2}"))

View File

@@ -0,0 +1,81 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun HeroComponent(
imageUrl: String,
title: String,
subtitle: String,
ctaText: String,
onPrimaryCta: () -> Unit,
heightDp: Int = 280
) {
Box(modifier = Modifier
.fillMaxWidth()
.height(heightDp.dp)) {
AsyncImage(
model = imageUrl,
contentDescription = "Hero Image",
modifier = Modifier
.fillMaxWidth()
.height(heightDp.dp),
contentScale = ContentScale.Crop
)
Box(modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(Color(0x66000000), Color(0x00000000), Color(0x88000000))
)
)
)
Box(modifier = Modifier
.matchParentSize()
.padding(20.dp)) {
Column(modifier = Modifier.align(Alignment.CenterStart)) {
Text(text = title, style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = Color.White)
}
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.clip(RoundedCornerShape(12.dp)),
color = MaterialTheme.colorScheme.primary
) {
Button(
onClick = onPrimaryCta,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
modifier = Modifier
) {
Text(ctaText, color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 12.dp))
}
}
}
}
}

View File

@@ -0,0 +1,86 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.window.Dialog
import coil.request.ImageRequest
import de.harheimertc.R
import de.harheimertc.repositories.GalleryImage
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ImageGrid(images: List<GalleryImage>, modifier: Modifier = Modifier) {
val selected = remember { mutableStateOf<GalleryImage?>(null) }
val context = LocalContext.current
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
items(images) { img ->
val description = stringResource(R.string.gallery_image_description, img.title.ifBlank { img.id })
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(img.previewUrl)
.size(300, 300)
.crossfade(true)
.build(),
contentDescription = description,
modifier = Modifier
.aspectRatio(1f)
.semantics { contentDescription = description }
.clickable { selected.value = img },
contentScale = ContentScale.Crop
)
}
}
}
if (selected.value != null) {
Dialog(onDismissRequest = { selected.value = null }) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(contentAlignment = Alignment.Center) {
AsyncImage(
model = selected.value?.imageUrl,
contentDescription = selected.value?.title ?: stringResource(R.string.gallery_title),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
Button(
onClick = { selected.value = null },
modifier = Modifier
.align(Alignment.TopEnd)
.semantics { contentDescription = context.getString(R.string.gallery_close_image) },
colors = ButtonDefaults.buttonColors(),
) {
Text(stringResource(R.string.gallery_upload_hide))
}
}
}
}
}
}

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

@@ -0,0 +1,205 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@Composable
fun NativeRichTextEditor(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
var fieldValue by remember(value) { mutableStateOf(TextFieldValue(value, TextRange(value.length))) }
var linkDialog by remember { mutableStateOf(false) }
var imageDialog by remember { mutableStateOf(false) }
fun commit(next: TextFieldValue) {
fieldValue = next
onValueChange(normalizeEmptyHtml(next.text))
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(label, style = MaterialTheme.typography.titleLarge)
ToolbarRow(
onAction = { action ->
when (action) {
RichTextAction.Link -> linkDialog = true
RichTextAction.Image -> imageDialog = true
RichTextAction.Clean -> commit(fieldValue.copy(text = stripHtml(fieldValue.text), selection = TextRange(stripHtml(fieldValue.text).length)))
else -> commit(applyAction(fieldValue, action))
}
},
)
OutlinedTextField(
value = fieldValue,
onValueChange = { commit(it) },
label = { Text("HTML-Inhalt") },
minLines = 12,
modifier = Modifier.fillMaxWidth(),
)
Surface(color = Color(0xFFF4F4F5), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Vorschau", style = MaterialTheme.typography.labelLarge)
if (fieldValue.text.isBlank()) Text("Noch kein Inhalt.", color = Color(0xFF71717A)) else RichText(fieldValue.text)
}
}
}
if (linkDialog) {
UrlDialog(
title = "Link einfügen",
placeholder = "https://...",
onDismiss = { linkDialog = false },
onConfirm = { url ->
commit(applyLink(fieldValue, url))
linkDialog = false
},
)
}
if (imageDialog) {
UrlDialog(
title = "Bild einfügen",
placeholder = "https://.../bild.jpg",
onDismiss = { imageDialog = false },
onConfirm = { url ->
commit(insertHtml(fieldValue, """<p><img src="${escapeHtml(url)}"></p>"""))
imageDialog = false
},
)
}
}
@Composable
private fun ToolbarRow(onAction: (RichTextAction) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RichTextAction.entries.forEach { action ->
AssistChip(onClick = { onAction(action) }, label = { Text(action.label) })
}
}
}
@Composable
private fun UrlDialog(title: String, placeholder: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var value by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(placeholder) }, singleLine = true)
},
confirmButton = {
Button(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text("Einfügen") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Abbrechen") }
},
)
}
private enum class RichTextAction(val label: String) {
H1("H1"),
H2("H2"),
H3("H3"),
Bold("B"),
Italic("I"),
Underline("U"),
Strike("S"),
Color("Farbe"),
Background("Marker"),
OrderedList("1."),
BulletList(""),
AlignCenter("Zentriert"),
Link("Link"),
Image("Bild"),
Blockquote("Zitat"),
CodeBlock("Code"),
Clean("Clean"),
}
private fun applyAction(value: TextFieldValue, action: RichTextAction): TextFieldValue = when (action) {
RichTextAction.H1 -> wrapBlock(value, "h1")
RichTextAction.H2 -> wrapBlock(value, "h2")
RichTextAction.H3 -> wrapBlock(value, "h3")
RichTextAction.Bold -> wrapInline(value, "strong")
RichTextAction.Italic -> wrapInline(value, "em")
RichTextAction.Underline -> wrapInline(value, "u")
RichTextAction.Strike -> wrapInline(value, "s")
RichTextAction.Color -> wrapInline(value, "span", " style=\"color: #dc2626;\"")
RichTextAction.Background -> wrapInline(value, "span", " style=\"background-color: #fef3c7;\"")
RichTextAction.OrderedList -> wrapLines(value, "ol")
RichTextAction.BulletList -> wrapLines(value, "ul")
RichTextAction.AlignCenter -> wrapSelection(value, """<p class="ql-align-center">""", "</p>")
RichTextAction.Blockquote -> wrapBlock(value, "blockquote")
RichTextAction.CodeBlock -> wrapSelection(value, """<pre class="ql-syntax" spellcheck="false">""", "</pre>")
RichTextAction.Link,
RichTextAction.Image,
RichTextAction.Clean -> value
}
private fun applyLink(value: TextFieldValue, url: String): TextFieldValue {
val safeUrl = escapeHtml(url)
val label = selectedText(value).ifBlank { safeUrl }
return replaceSelection(value, """<a href="$safeUrl">$label</a>""")
}
private fun wrapInline(value: TextFieldValue, tag: String, attrs: String = ""): TextFieldValue =
wrapSelection(value, "<$tag$attrs>", "</$tag>")
private fun wrapBlock(value: TextFieldValue, tag: String): TextFieldValue =
wrapSelection(value, "<$tag>", "</$tag>")
private fun wrapLines(value: TextFieldValue, listTag: String): TextFieldValue {
val lines = selectedText(value).ifBlank { "Listeneintrag" }
.lines()
.filter { it.isNotBlank() }
.joinToString("") { "<li>${escapeHtml(it)}</li>" }
return replaceSelection(value, "<$listTag>$lines</$listTag>")
}
private fun wrapSelection(value: TextFieldValue, prefix: String, suffix: String): TextFieldValue =
replaceSelection(value, prefix + selectedText(value).ifBlank { "Text" } + suffix)
private fun insertHtml(value: TextFieldValue, html: String): TextFieldValue = replaceSelection(value, html)
private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue {
val start = value.selection.min.coerceIn(0, value.text.length)
val end = value.selection.max.coerceIn(0, value.text.length)
val next = value.text.replaceRange(start, end, replacement)
val cursor = start + replacement.length
return TextFieldValue(next, TextRange(cursor))
}
private fun selectedText(value: TextFieldValue): String {
val start = value.selection.min.coerceIn(0, value.text.length)
val end = value.selection.max.coerceIn(0, value.text.length)
return value.text.substring(start, end)
}
// HTML helper functions moved to RichTextUtils.kt for reuse and testing

View File

@@ -0,0 +1,68 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun PendingPage(
navController: NavController,
title: String,
webPath: String,
showBackNavigation: Boolean,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Column(
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
Text("Webseite: $webPath", color = Accent500)
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
Text(
"Die native Android-Seite wird in einem der nächsten Portierungsschritte umgesetzt.",
color = Primary900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(18.dp),
)
}
}
}
}

View File

@@ -0,0 +1,31 @@
package de.harheimertc.ui.components
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
@Composable
fun RichText(
html: String,
modifier: Modifier = Modifier.fillMaxWidth(),
) {
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context).apply {
textSize = 17f
setTextColor(android.graphics.Color.rgb(63, 63, 70))
movementMethod = LinkMovementMethod.getInstance()
setLineSpacing(0f, 1.2f)
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
},
)
}

View File

@@ -0,0 +1,15 @@
package de.harheimertc.ui.components
fun normalizeEmptyHtml(value: String): String =
if (stripHtml(value).isBlank() && !value.contains("<img", ignoreCase = true)) "" else value
fun stripHtml(value: String): String = value
.replace(Regex("<[^>]+>"), "")
.replace("&nbsp;", " ")
.trim()
fun escapeHtml(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")

View File

@@ -0,0 +1,59 @@
package de.harheimertc.ui.navigation
sealed class Destinations(val route: String) {
object Home : Destinations("home")
object VereinAbout : Destinations("verein/about")
object Vorstand : Destinations("verein/vorstand")
object Geschichte : Destinations("verein/geschichte")
object Satzung : Destinations("verein/satzung")
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
object Links : Destinations("verein/links")
object Impressum : Destinations("impressum")
object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
fun create(slug: String, season: String? = null): String {
val encodedSlug = android.net.Uri.encode(slug)
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
}
}
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
fun create(slug: String): String = "mannschaft/$slug"
}
object Termine : Destinations("termine")
object Spielplan : Destinations("spielplan")
object Spielsysteme : Destinations("mannschaften/spielsysteme")
object Training : Destinations("training")
object Trainer : Destinations("training/trainer")
object Anfaenger : Destinations("training/anfaenger")
object Regeln : Destinations("training/regeln")
object Gallery : Destinations("gallery")
object NewsletterSubscribe : Destinations("newsletter/subscribe")
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
object NewsletterConfirm : Destinations("newsletter/confirm")
object NewsletterConfirmed : Destinations("newsletter/confirmed")
object NewsletterUnsubscribed : Destinations("newsletter/unsubscribed")
object Contact : Destinations("contact")
object Membership : Destinations("membership")
object Login : Destinations("login")
object PasswordReset : Destinations("passwordReset")
object Register : Destinations("register")
object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder")
object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil")
object NotificationSettings : Destinations("intern/benachrichtigungen")
object MemberApi : Destinations("intern/api")
object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte")
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
object CmsNewsletter : Destinations("cms/newsletter")
object CmsContactRequests : Destinations("cms/kontaktanfragen")
object CmsEinstellungen : Destinations("cms/einstellungen")
object CmsBenutzer : Destinations("cms/benutzer")
object CmsPasswordResetDiagnostics : Destinations("cms/passwort-reset-diagnose")
object Cms : Destinations("cms")
}

View File

@@ -0,0 +1,377 @@
package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader
@Composable
fun NavGraph(
navController: NavHostController,
startDestination: String = Destinations.Home.route,
navigationViewModelParam: NavigationViewModel? = null,
) {
val navigationViewModel: NavigationViewModel = navigationViewModelParam ?: hiltViewModel()
val backStackEntry = navController.currentBackStackEntryAsState().value
val route = backStackEntry?.destination?.route
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
backStackEntry.arguments?.getString("slug")?.let { slug ->
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
}
} else route
val navigationState by navigationViewModel.state.collectAsState()
LaunchedEffect(currentRoute) {
navigationViewModel.refreshSession()
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) {
navigationState.connectionNote?.let { message ->
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
Text(
text = message,
color = Color(0xFF7C2D12),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
if (persistentNavigation) {
AppNavigationHeader(
selectedRoute = currentRoute,
onNavigate = navController::navigateTopLevel,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
webTabletNavigation = true,
navigationState = navigationState,
)
}
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.weight(1f),
) {
composable(Destinations.Home.route) {
de.harheimertc.ui.screens.home.HomeScreen(
navController = navController,
showNavigationHeader = !persistentNavigation,
navigationViewModel = navigationViewModel,
viewModel = hiltViewModel(),
)
}
composable(Destinations.VereinAbout.route) {
de.harheimertc.ui.screens.publicpages.AboutScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Vorstand.route) {
de.harheimertc.ui.screens.publicpages.VorstandScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Geschichte.route) {
de.harheimertc.ui.screens.publicpages.GeschichteScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Satzung.route) {
de.harheimertc.ui.screens.publicpages.SatzungScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Vereinsmeisterschaften.route) {
de.harheimertc.ui.screens.publicpages.VereinsmeisterschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Links.route) {
de.harheimertc.ui.screens.publicpages.LinksScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Impressum.route) {
de.harheimertc.ui.screens.publicpages.ImpressumScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Mannschaften.route) {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable("mannschaften/herren") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable("mannschaften/damen") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable("mannschaften/jugend") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable(
route = Destinations.MannschaftDetail.route,
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
season = entry.arguments?.getString("season"),
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
season = null,
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Termine.route) {
de.harheimertc.ui.screens.termine.TermineScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Spielplan.route) {
de.harheimertc.ui.screens.spielplan.SpielplanScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Spielsysteme.route) {
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable("spielsysteme") {
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation)
}
composable(Destinations.Training.route) {
de.harheimertc.ui.screens.training.TrainingScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Trainer.route) {
de.harheimertc.ui.screens.training.TrainerScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Anfaenger.route) {
de.harheimertc.ui.screens.training.AnfaengerScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Regeln.route) {
de.harheimertc.ui.screens.publicpages.RegelnScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable("tt-regeln") {
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
}
composable("verein/tt-regeln") {
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
}
composable(Destinations.Gallery.route) {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable("galerie") {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable(Destinations.NewsletterSubscribe.route) {
de.harheimertc.ui.screens.newsletter.NewsletterSubscribeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterUnsubscribe.route) {
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterConfirm.route) {
de.harheimertc.ui.screens.newsletter.NewsletterConfirmScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
token = null,
)
}
composable(Destinations.NewsletterConfirmed.route) {
de.harheimertc.ui.screens.newsletter.NewsletterConfirmedScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterUnsubscribed.route) {
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribedScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Contact.route) {
de.harheimertc.ui.screens.contact.ContactScreen()
}
composable(Destinations.Membership.route) {
de.harheimertc.ui.screens.membership.MembershipScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Login.route) {
de.harheimertc.ui.screens.login.LoginScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.PasswordReset.route) {
de.harheimertc.ui.screens.login.PasswordResetScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Register.route) {
de.harheimertc.ui.screens.login.RegisterScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable("ueber-uns") {
de.harheimertc.ui.screens.publicpages.AboutScreen(navController, !persistentNavigation)
}
composable("geschichte") {
de.harheimertc.ui.screens.publicpages.GeschichteScreen(navController, !persistentNavigation)
}
composable("satzung") {
de.harheimertc.ui.screens.publicpages.SatzungScreen(navController, !persistentNavigation)
}
composable(Destinations.MemberArea.route) {
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
navigationState = navigationState,
)
}
composable(Destinations.Members.route) {
de.harheimertc.ui.screens.memberarea.MembersScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Qttr.route) {
de.harheimertc.ui.screens.memberarea.QttrScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberNews.route) {
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Profile.route) {
de.harheimertc.ui.screens.profile.ProfileScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NotificationSettings.route) {
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
navigationState = navigationState,
)
}
composable(Destinations.MemberApi.route) {
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.CmsStartseite.route) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsInhalte.route) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsVereinsmeisterschaften.route) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsSportbetrieb.route) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsMitgliederverwaltung.route) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsNewsletter.route) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsContactRequests.route) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsEinstellungen.route) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsBenutzer.route) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsPasswordResetDiagnostics.route) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
}
composable(Destinations.Cms.route) {
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
}
}
}
}
}
private fun NavHostController.navigateTopLevel(route: String) {
val isTeamDetail = route.startsWith("mannschaften/") &&
route != Destinations.Spielsysteme.route
navigate(route) {
launchSingleTop = !isTeamDetail
restoreState = !isTeamDetail
popUpTo(Destinations.Home.route) {
saveState = !isTeamDetail
}
}
}

View File

@@ -0,0 +1,115 @@
package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NavigationUiState(
val teams: List<Mannschaft> = emptyList(),
val hasGalleryImages: Boolean = false,
val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(),
val connectionNote: String? = null,
) {
val isAdmin: Boolean get() = "admin" in roles
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
}
@HiltViewModel
class NavigationViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository,
private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository,
private val authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor,
private val pushTokenRepository: PushTokenRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state
init {
loadNavigationData()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
_state.value = _state.value.copy(
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
)
wasOnline = online
}
}
}
fun loadNavigationData() {
viewModelScope.launch {
val teams = async { mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()) }
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await()
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = NavigationUiState(
teams = teams.await(),
hasGalleryImages = gallery.await(),
loggedIn = loggedIn,
roles = status.navigationRoles(),
connectionNote = null,
)
if (loggedIn) registerPushToken()
}
}
fun refreshSession() {
viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = _state.value.copy(
loggedIn = loggedIn,
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

@@ -0,0 +1,3 @@
package de.harheimertc.ui.screens.cms
// Placeholder: functionality moved to CmsScreens.kt (CmsUserListPage / UserCard)

View File

@@ -0,0 +1,306 @@
package de.harheimertc.ui.screens.cms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.compose.ui.platform.LocalContext
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
var selection by remember { mutableStateOf(setOf<String>()) }
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
val context = LocalContext.current
var showSuccessDialog by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(state.message) {
if (!state.message.isNullOrBlank()) showSuccessDialog = true
}
// Local dialog state for create/edit + delete confirmation (hoisted)
var dialogOpen by remember { mutableStateOf(false) }
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
var editing by remember { mutableStateOf<NewsDto?>(null) }
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
var isPublic by remember { mutableStateOf(false) }
var isHidden by remember { mutableStateOf(false) }
var expiresAt by remember { mutableStateOf("") } // format: yyyy-MM-dd'T'HH:mm
val dtFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
val displayFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy, HH:mm", Locale.GERMANY)
fun convertUTCToLocal(utc: String?): String {
if (utc.isNullOrBlank()) return ""
return try {
val instant = Instant.parse(utc)
LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).format(dtFormatter)
} catch (e: Exception) { "" }
}
fun convertLocalToUTC(local: String?): String? {
if (local.isNullOrBlank()) return null
return try {
val ldt = LocalDateTime.parse(local, dtFormatter)
ldt.atZone(ZoneId.systemDefault()).toInstant().toString()
} catch (e: Exception) { null }
}
// open create
fun openAdd() {
editing = null
title = ""
content = ""
isPublic = false
isHidden = false
expiresAt = ""
dialogOpen = true
}
// open edit
fun openEdit(item: NewsDto) {
editing = item
title = item.title
content = item.content
isPublic = item.isPublic
isHidden = item.isHidden
expiresAt = convertUTCToLocal(item.expiresAt)
dialogOpen = true
}
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
if (state.loading) item { LoadingState("News werden geladen...") }
item {
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
}
item {
if (canWrite) Button(onClick = { openAdd() }, modifier = Modifier.fillMaxWidth()) { Text("News erstellen") }
}
item {
FormMessages(state.error, state.message)
}
if (!state.loading && state.news.isEmpty()) item { Text("Noch keine News vorhanden.", modifier = Modifier.padding(12.dp)) }
// selection state for bulk actions (moved to outer scope)
items(state.news) { news ->
val selected = news.id?.let { selection.contains(it) } ?: false
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
id?.let {
selection = if (sel) selection + it else selection - it
}
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
}
// bulk action bar
if (selection.isNotEmpty()) {
item {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), false) }) { Text("Als nicht-öffentlich markieren") }
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), true) }) { Text("Ausblenden") }
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), false) }) { Text("Einblenden") }
Button(onClick = { /* confirm then delete */ deletingIds = selection.toList() }) { Text("Löschen") }
}
}
}
}
// (moved earlier)
// delete confirmation dialog
if (deletingIds != null) {
AlertDialog(
onDismissRequest = { deletingIds = null },
title = { Text("News löschen") },
text = { Text("Möchten Sie die ausgewählten News wirklich löschen?") },
confirmButton = { Button(onClick = {
deletingIds?.let { viewModel.bulkDelete(it) }
deletingIds = null
selection = emptySet()
}) { Text("Löschen") } },
dismissButton = { TextButton(onClick = { deletingIds = null }) { Text("Abbrechen") } },
)
}
// dialog for create/edit
if (dialogOpen) {
AlertDialog(
onDismissRequest = { dialogOpen = false },
title = { Text(if (editing == null) "News erstellen" else "News bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Titel *") }, modifier = Modifier.fillMaxWidth())
NativeRichTextEditor(content, { content = it }, "Inhalt *")
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it })
Text("Öffentliche News (auf Startseite anzeigen)", modifier = Modifier.padding(start = 8.dp))
}
if (isPublic) {
// read-only datetime field that opens native pickers
OutlinedTextField(
value = expiresAt,
onValueChange = { /* no-op: controlled by pickers */ },
label = { Text("Ablaufdatum (optional)") },
modifier = Modifier
.fillMaxWidth()
.clickable {
// open date then time picker
val now = java.util.Calendar.getInstance()
val year = now.get(java.util.Calendar.YEAR)
val month = now.get(java.util.Calendar.MONTH)
val day = now.get(java.util.Calendar.DAY_OF_MONTH)
android.app.DatePickerDialog(context, { _, y, m, d ->
val hour = now.get(java.util.Calendar.HOUR_OF_DAY)
val minute = now.get(java.util.Calendar.MINUTE)
android.app.TimePickerDialog(context, { _, h, min ->
val ldt = LocalDateTime.of(y, m + 1, d, h, min)
expiresAt = ldt.format(dtFormatter)
}, hour, minute, true).show()
}, year, month, day).show()
},
readOnly = true,
)
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = isHidden, onCheckedChange = { isHidden = it })
Text("News ausblenden", modifier = Modifier.padding(start = 8.dp))
}
}
val err = state.error
if (err != null) {
Text(err, color = Color(0xFF842029))
}
}
},
confirmButton = {
Button(onClick = {
val req = NewsSaveRequest(
id = editing?.id,
title = title,
content = content,
isPublic = isPublic,
isHidden = isHidden,
expiresAt = convertLocalToUTC(expiresAt),
)
viewModel.saveNews(req)
dialogOpen = false
}, enabled = !state.saving) { Text(if (state.saving) "Speichert..." else "Speichern") }
},
dismissButton = {
TextButton(onClick = { dialogOpen = false }) { Text("Abbrechen") }
}
)
}
if (showSuccessDialog && !state.message.isNullOrBlank()) {
AlertDialog(
onDismissRequest = { showSuccessDialog = false },
title = { Text("Erfolg") },
text = { Text(state.message ?: "") },
confirmButton = { Button(onClick = { showSuccessDialog = false }) { Text("OK") } },
)
}
}
@Composable
private fun NewsListItem(
news: NewsDto,
selected: Boolean = false,
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
onEdit: (NewsDto) -> Unit,
onDelete: (String) -> Unit,
) {
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
if (news.isPublic) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF0EA5A6)))
Text("Öffentlich", modifier = Modifier.padding(start = 6.dp))
}
}
if (news.isHidden) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color.Gray))
Text("Ausgeblendet", modifier = Modifier.padding(start = 6.dp))
}
}
val expired = news.expiresAt?.let {
try { Instant.parse(it).isBefore(Instant.now()) || Instant.parse(it).equals(Instant.now()) } catch (e: Exception) { false }
} ?: false
if (expired) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFB91C1C)))
Text("Abgelaufen", modifier = Modifier.padding(start = 6.dp))
}
}
}
Row(modifier = Modifier.padding(top = 4.dp)) {
Text(news.author ?: "-", modifier = Modifier.padding(end = 12.dp))
Text(news.created ?: "-")
}
if (news.updated != null && news.updated != news.created) {
Text("Aktualisiert: ${news.updated}")
}
}
Row {
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,464 @@
package de.harheimertc.ui.screens.cms
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import de.harheimertc.ui.util.ErrorMapper
data class CmsUiState(
val loading: Boolean = true,
val saving: Boolean = false,
val error: String? = null,
val message: String? = null,
val config: ConfigResponse? = null,
val users: List<CmsUserDto> = emptyList(),
val contactRequests: List<ContactRequestDto> = emptyList(),
val newsletters: List<NewsletterDto> = emptyList(),
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
val passwordResetMatchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
val passwordResetRetentionHours: Int = 72,
val passwordResetSearchTerm: String = "",
val passwordResetFailedOnly: Boolean = true,
val news: List<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = emptyList(),
)
@HiltViewModel
class CmsViewModel @Inject constructor(
private val repository: CmsRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() {
private val _state = MutableStateFlow(CmsUiState())
val state: StateFlow<CmsUiState> = _state
init {
load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
val configRes = async { repository.config() }
val usersRes = async { repository.users() }
val requestsRes = async { repository.contactRequests() }
val newslettersRes = async { repository.newsletters() }
val groupsRes = async { repository.newsletterGroups() }
val newsRes = async { repository.news() }
val diagnosticsRes = async {
repository.passwordResetDiagnostics(
email = _state.value.passwordResetSearchTerm.takeIf { it.isNotBlank() },
failedOnly = _state.value.passwordResetFailedOnly,
)
}
val meisterschaftenRes = async { repository.vereinsmeisterschaften() }
val configResult = configRes.await()
val usersResult = usersRes.await()
val requestsResult = requestsRes.await()
val newslettersResult = newslettersRes.await()
val groupsResult = groupsRes.await()
val newsResult = newsRes.await()
val diagnosticsResult = diagnosticsRes.await()
val meisterschaftenResult = meisterschaftenRes.await()
val errors = listOfNotNull(
ErrorMapper.mapError(configResult.exceptionOrNull()),
ErrorMapper.mapError(usersResult.exceptionOrNull()),
ErrorMapper.mapError(requestsResult.exceptionOrNull()),
ErrorMapper.mapError(newslettersResult.exceptionOrNull()),
ErrorMapper.mapError(groupsResult.exceptionOrNull()),
ErrorMapper.mapError(newsResult.exceptionOrNull()),
ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()),
ErrorMapper.mapError(meisterschaftenResult.exceptionOrNull()),
)
// Sort users so that pending (inactive) users come first,
// followed by active users sorted by display name (case-insensitive).
val fetchedUsers = usersResult.getOrNull()?.users.orEmpty()
val pendingUsers = fetchedUsers.filter { it.active == false }
val activeUsers = fetchedUsers.filter { it.active == true }
.sortedBy { it.name.lowercase() }
val orderedUsers = pendingUsers + activeUsers
_state.value = CmsUiState(
loading = false,
error = if (errors.isNotEmpty()) errors.joinToString("; ") else null,
config = configResult.getOrNull(),
users = orderedUsers,
contactRequests = requestsResult.getOrNull().orEmpty(),
newsletters = newslettersResult.getOrNull()?.newsletters.orEmpty(),
newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
news = newsResult.getOrNull()?.news.orEmpty(),
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
passwordResetMatchingUsers = diagnosticsResult.getOrNull()?.matchingUsers.orEmpty(),
passwordResetRetentionHours = diagnosticsResult.getOrNull()?.retentionHours ?: 72,
passwordResetSearchTerm = diagnosticsResult.getOrNull()?.searchedEmail.orEmpty(),
passwordResetFailedOnly = _state.value.passwordResetFailedOnly,
meisterschaften = meisterschaftenResult.getOrNull().orEmpty(),
)
}
}
fun loadPasswordResetDiagnostics(email: String, failedOnly: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(
loading = true,
error = null,
passwordResetSearchTerm = email.trim(),
passwordResetFailedOnly = failedOnly,
)
repository.passwordResetDiagnostics(
email = email.trim().takeIf { it.isNotBlank() },
failedOnly = failedOnly,
)
.onSuccess { response ->
_state.value = _state.value.copy(
loading = false,
passwordResetRetentionHours = response.retentionHours,
passwordResetMatchingUsers = response.matchingUsers,
passwordResetAttempts = response.attempts,
passwordResetSearchTerm = response.searchedEmail.orEmpty(),
)
}
.onFailure { err ->
_state.value = _state.value.copy(
loading = false,
passwordResetMatchingUsers = emptyList(),
passwordResetAttempts = emptyList(),
error = ErrorMapper.mapError(err) ?: "Passwort-Reset-Diagnose konnte nicht geladen werden.",
)
}
}
}
fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.saveVereinsmeisterschaften(results)
.onSuccess { response ->
_state.value = _state.value.copy(
saving = false,
meisterschaften = results,
message = response.message ?: "Vereinsmeisterschaften gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
saving = false,
error = ErrorMapper.mapError(err) ?: "Vereinsmeisterschaften konnten nicht gespeichert werden.",
)
}
}
}
fun saveConfig(config: ConfigResponse) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.saveConfig(config)
.onSuccess { saved ->
_state.value = _state.value.copy(
saving = false,
config = saved,
message = "Inhalt gespeichert.",
)
}
.onFailure {
_state.value = _state.value.copy(
saving = false,
error = ErrorMapper.mapError(it) ?: "Inhalt konnte nicht gespeichert werden.",
)
}
}
}
fun saveNews(request: NewsSaveRequest) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.saveNews(request)
.onSuccess { msg ->
// refresh news list directly to preserve message
val newsRes = repository.news()
_state.value = _state.value.copy(
saving = false,
news = newsRes.getOrNull()?.news.orEmpty(),
message = msg.message ?: "Nachricht gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gespeichert werden.")
}
}
}
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
val existing = _state.value.news.find { it.id == id }
if (existing != null) {
val req = NewsSaveRequest(
id = existing.id,
title = existing.title,
content = existing.content,
isPublic = makePublic,
isHidden = existing.isHidden,
expiresAt = existing.expiresAt,
)
repository.saveNews(req)
}
}
val newsRes = repository.news()
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
}
}
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
val existing = _state.value.news.find { it.id == id }
if (existing != null) {
val req = NewsSaveRequest(
id = existing.id,
title = existing.title,
content = existing.content,
isPublic = existing.isPublic,
isHidden = makeHidden,
expiresAt = existing.expiresAt,
)
repository.saveNews(req)
}
}
val newsRes = repository.news()
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
}
}
fun bulkDelete(ids: List<String>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
repository.deleteNews(id)
}
val newsRes = repository.news()
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Löschung abgeschlossen.")
}
}
fun deleteNews(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNews(id)
.onSuccess { msg ->
val newsRes = repository.news()
_state.value = _state.value.copy(
saving = false,
news = newsRes.getOrNull()?.news.orEmpty(),
message = msg.message ?: "Nachricht gelöscht.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gelöscht werden.")
}
}
}
// --- Newsletter (B4)
fun saveNewsletter(request: de.harheimertc.data.NewsletterCreateRequest) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.createNewsletter(request)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(
saving = false,
newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(),
message = res.message ?: "Newsletter gespeichert",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gespeichert werden.")
}
}
}
fun updateNewsletter(id: String, patch: Map<String, Any?>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateNewsletter(id, patch)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter aktualisiert")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht aktualisiert werden.") }
}
}
fun sendNewsletter(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.sendNewsletter(id)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter versendet")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht versendet werden.") }
}
}
fun deleteNewsletter(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNewsletter(id)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter gelöscht")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gelöscht werden.") }
}
}
// --- Newsletter Groups (B4)
fun createNewsletterGroup(payload: Map<String, Any?>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.createNewsletterGroup(payload)
.onSuccess { res ->
val groupsRes = repository.newsletterGroups()
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe erstellt")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht erstellt werden.") }
}
}
fun updateNewsletterGroup(id: String, patch: Map<String, Any?>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateNewsletterGroup(id, patch)
.onSuccess { res ->
val groupsRes = repository.newsletterGroups()
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe aktualisiert")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht aktualisiert werden.") }
}
}
fun deleteNewsletterGroup(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNewsletterGroup(id)
.onSuccess { res ->
val groupsRes = repository.newsletterGroups()
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe gelöscht")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht gelöscht werden.") }
}
}
// --- User management actions (B2)
fun updateUserRoles(id: String, roles: List<String>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateUserRoles(id, roles)
.onSuccess { msg ->
val usersRes = repository.users()
_state.value = _state.value.copy(
saving = false,
users = usersRes.getOrNull()?.users.orEmpty(),
message = msg.message ?: "Rollen aktualisiert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Rollen konnten nicht aktualisiert werden.")
}
}
}
fun setUserActive(id: String, active: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateUserActive(id, active)
.onSuccess { msg ->
val usersRes = repository.users()
_state.value = _state.value.copy(
saving = false,
users = usersRes.getOrNull()?.users.orEmpty(),
message = msg.message ?: if (active) "Benutzer aktiviert." else "Benutzer deaktiviert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Benutzerstatus konnte nicht geändert werden.")
}
}
}
fun resendInvite(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.resendInvite(id)
.onSuccess { msg ->
_state.value = _state.value.copy(saving = false, message = msg.message ?: "Einladung erneut gesendet.")
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Einladung konnte nicht gesendet werden.")
}
}
}
// --- Contact requests (B3)
fun replyToContactRequest(id: String, message: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.replyToContactRequest(id, message)
.onSuccess {
val reqs = repository.contactRequests()
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Antwort gesendet.")
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Antwort konnte nicht gesendet werden.")
}
}
}
fun toggleContactRequestStatus(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.toggleContactRequestStatus(id)
.onSuccess {
val reqs = repository.contactRequests()
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Status aktualisiert.")
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Status konnte nicht geändert werden.")
}
}
}
}

View File

@@ -0,0 +1,46 @@
package de.harheimertc.ui.screens.contact
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.components.ValidatedTextField
@Composable
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
val name by viewModel.name.collectAsState()
val email by viewModel.email.collectAsState()
val message by viewModel.message.collectAsState()
val sending by viewModel.sending.collectAsState()
val result by viewModel.result.collectAsState()
val fieldErrors by viewModel.fieldErrors.collectAsState()
Surface(modifier = Modifier.padding(16.dp)) {
Column {
ValidatedTextField(name, viewModel::onName, "Name", error = fieldErrors["name"])
ValidatedTextField(email, viewModel::onEmail, "E-Mail", error = fieldErrors["email"])
ValidatedTextField(
value = message,
onValueChange = viewModel::onMessage,
label = "Nachricht",
error = fieldErrors["message"],
singleLine = false,
minLines = 4,
)
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
Text(if (sending) "Sende…" else "Absenden")
}
if (result != null) {
Text(text = result!!, modifier = Modifier.padding(top = 8.dp))
}
}
}
}

View File

@@ -0,0 +1,92 @@
package de.harheimertc.ui.screens.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ContactRequest
import de.harheimertc.repositories.ContactRepository
import de.harheimertc.ui.components.isValidEmail
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ContactViewModel @Inject constructor(private val repo: ContactRepository) : ViewModel() {
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name
private val _email = MutableStateFlow("")
val email: StateFlow<String> = _email
private val _message = MutableStateFlow("")
val message: StateFlow<String> = _message
private val _sending = MutableStateFlow(false)
val sending: StateFlow<Boolean> = _sending
private val _result = MutableStateFlow<String?>(null)
val result: StateFlow<String?> = _result
private val _fieldErrors = MutableStateFlow<Map<String, String>>(emptyMap())
val fieldErrors: StateFlow<Map<String, String>> = _fieldErrors
fun onName(v: String) {
_name.value = v
clearFieldError("name")
}
fun onEmail(v: String) {
_email.value = v
clearFieldError("email")
}
fun onMessage(v: String) {
_message.value = v
clearFieldError("message")
}
fun send() {
val n = _name.value.trim()
val e = _email.value.trim()
val m = _message.value.trim()
val errors = buildMap {
if (n.isEmpty()) put("name", "Bitte geben Sie Ihren Namen ein.")
if (e.isEmpty()) put("email", "Bitte geben Sie Ihre E-Mail-Adresse ein.")
else if (!isValidEmail(e)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
if (m.isEmpty()) put("message", "Bitte geben Sie eine Nachricht ein.")
else if (m.length < 10) put("message", "Die Nachricht sollte mindestens 10 Zeichen haben.")
}
if (errors.isNotEmpty()) {
_fieldErrors.value = errors
_result.value = "Bitte prüfen Sie die markierten Felder."
return
}
viewModelScope.launch {
_sending.value = true
try {
val resp = repo.sendContact(ContactRequest(n, e, m))
if (resp.isSuccessful) {
_result.value = "Nachricht gesendet"
_fieldErrors.value = emptyMap()
_name.value = ""
_email.value = ""
_message.value = ""
} else {
_result.value = "Fehler: ${resp.code()}"
}
} catch (e: Exception) {
_result.value = "Netzwerkfehler"
} finally {
_sending.value = false
}
}
}
private fun clearFieldError(field: String) {
if (_fieldErrors.value.containsKey(field)) {
_fieldErrors.value = _fieldErrors.value - field
}
_result.value = null
}
}

View File

@@ -0,0 +1,146 @@
package de.harheimertc.ui.screens.gallery
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.R
import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.ImageGrid
@Composable
fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
val images by viewModel.images.collectAsState()
val loading by viewModel.loading.collectAsState()
val error by viewModel.error.collectAsState()
val uploading by viewModel.uploading.collectAsState()
val message by viewModel.message.collectAsState()
val canUpload by viewModel.canUpload.collectAsState()
var selectedUri by remember { mutableStateOf<android.net.Uri?>(null) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var isPublic by remember { mutableStateOf(false) }
var showUpload by remember { mutableStateOf(false) }
val context = LocalContext.current
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
selectedUri = uri
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
text = stringResource(R.string.gallery_title),
modifier = Modifier.semantics { heading() },
)
Spacer(Modifier.height(12.dp))
if (canUpload) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.gallery_upload_title), modifier = Modifier.weight(1f))
OutlinedButton(onClick = { showUpload = !showUpload }) {
Text(if (showUpload) stringResource(R.string.gallery_upload_hide) else stringResource(R.string.gallery_upload_show))
}
}
if (showUpload) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { picker.launch("image/*") },
enabled = !uploading,
modifier = Modifier.semantics {
contentDescription = context.getString(R.string.gallery_upload_choose_file)
},
) {
Text(selectedUri?.lastPathSegment ?: stringResource(R.string.gallery_upload_choose_file))
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.gallery_upload_image_title)) },
modifier = Modifier.fillMaxWidth(),
enabled = !uploading,
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(R.string.gallery_upload_description)) },
modifier = Modifier.fillMaxWidth(),
enabled = !uploading,
minLines = 2,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it }, enabled = !uploading)
Text(stringResource(R.string.gallery_upload_public))
}
Button(
onClick = {
selectedUri?.let { uri ->
viewModel.upload(uri, title, description, isPublic)
}
},
enabled = selectedUri != null && title.isNotBlank() && !uploading,
) {
Text(if (uploading) stringResource(R.string.gallery_uploading) else stringResource(R.string.gallery_upload_submit))
}
}
}
}
Spacer(Modifier.height(12.dp))
}
FormMessages(error = error, message = message)
Spacer(Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
if (loading) {
LoadingState("Galerie wird geladen...")
} else if (images.isEmpty()) {
Text(text = stringResource(R.string.gallery_empty))
} else {
ImageGrid(images = images, modifier = Modifier.height(520.dp))
}
}
}
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.load()
}
}

View File

@@ -0,0 +1,68 @@
package de.harheimertc.ui.screens.gallery
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import android.net.Uri
import de.harheimertc.repositories.GalleryImage
import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class GalleryViewModel @Inject constructor(
private val repo: GalleryRepository,
private val loginRepository: LoginRepository,
) : ViewModel() {
private val _images = MutableStateFlow<List<GalleryImage>>(emptyList())
val images: StateFlow<List<GalleryImage>> = _images
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
private val _uploading = MutableStateFlow(false)
val uploading: StateFlow<Boolean> = _uploading
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message
private val _canUpload = MutableStateFlow(false)
val canUpload: StateFlow<Boolean> = _canUpload
fun load() {
viewModelScope.launch {
_loading.value = true
_error.value = null
repo.fetchImages()
.onSuccess { _images.value = it.images }
.onFailure { _error.value = it.message ?: "Fehler" }
loginRepository.status()
.onSuccess { status ->
val roles = (status.roles + status.user?.roles.orEmpty() + listOfNotNull(status.role)).toSet()
_canUpload.value = roles.any { it in setOf("admin", "vorstand") }
}
_loading.value = false
}
}
fun upload(uri: Uri, title: String, description: String, isPublic: Boolean) {
viewModelScope.launch {
_uploading.value = true
_error.value = null
_message.value = null
repo.uploadImage(uri, title, description, isPublic)
.onSuccess {
_message.value = "Bild erfolgreich hochgeladen."
load()
}
.onFailure { _error.value = it.message ?: "Fehler beim Hochladen des Bildes" }
_uploading.value = false
}
}
}

View File

@@ -0,0 +1,832 @@
package de.harheimertc.ui.screens.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@Composable
fun HomeScreen(
navController: NavController,
showNavigationHeader: Boolean = true,
navigationViewModel: NavigationViewModel,
viewModel: HomeViewModel,
) {
val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
var editHomeSections by rememberSaveable { mutableStateOf(false) }
selectedNews?.let { item ->
AlertDialog(
onDismissRequest = { selectedNews = null },
title = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge)
}
},
text = { RichText(item.content) },
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
)
}
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color.White),
) {
if (showNavigationHeader) {
item {
AppNavigationHeader(
selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
navigationState = navigationState,
)
}
}
item {
HomeCustomizationSection(
sections = state.homepageSections,
spielplanSeasons = state.spielplanSeasons,
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
editEnabled = editHomeSections,
onToggleEdit = { editHomeSections = !editHomeSections },
onMoveUp = viewModel::moveSectionUp,
onMoveDown = viewModel::moveSectionDown,
onEnabledChange = viewModel::setSectionEnabled,
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
onReset = viewModel::resetSections,
)
}
if (state.error) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = viewModel::load) {
Text("Erneut versuchen")
}
}
}
}
if (state.debugDiagnostics.isNotEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Technische Diagnose (vorübergehend)",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
}
}
}
state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section)
when (section.id) {
"banner" -> item(key = "home_section_${sectionKey}_$index") {
WebHero(imageUrl = state.heroImageUrl)
}
"termine" -> item(key = "home_section_${sectionKey}_$index") {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
)
}
"spiele" -> item(key = "home_section_${sectionKey}_$index") {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
"aktuelles" -> {
if (state.news.isNotEmpty()) {
item(key = "home_section_${sectionKey}_$index") {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
)
}
}
}
"kontakt" -> item(key = "home_section_${sectionKey}_$index") {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
)
}
"training" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Training & Einstieg",
body = "Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot.",
action = "Zum Training",
onClick = { navController.navigate(Destinations.Training.route) },
)
}
"links" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Nützliche Links",
body = "Direkter Zugang zu Verbänden, Ergebnisdiensten und hilfreichen Portalen.",
action = "Links öffnen",
onClick = { navController.navigate(Destinations.Links.route) },
)
}
"vereinsmeisterschaften" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Vereinsmeisterschaften",
body = "Ergebnisse und Historie unserer Vereinsmeisterschaften.",
action = "Ergebnisse ansehen",
onClick = { navController.navigate(Destinations.Vereinsmeisterschaften.route) },
)
}
"spielplan_team" -> item(key = "home_section_${sectionKey}_$index") {
HomeSpielplanTeamWidgetSection(
section = section,
spiele = state.spielplanWidgetPreviews[sectionKey].orEmpty(),
error = state.spielplanWidgetErrors[sectionKey],
loading = state.widgetsLoading,
onOpenAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
}
}
item { HomeFooter() }
}
}
@Composable
private fun HomeCustomizationSection(
sections: List<HomepageSectionDto>,
spielplanSeasons: List<SeasonDto>,
spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
editEnabled: Boolean,
onToggleEdit: () -> Unit,
onMoveUp: (String) -> Unit,
onMoveDown: (String) -> Unit,
onEnabledChange: (String, Boolean) -> Unit,
onAddSpielplanWidget: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
onUpdateSpielplanWidget: (sectionKey: String, season: String, teamName: String, teamAgeGroup: String) -> Unit,
onReset: () -> Unit,
) {
var addSeason by rememberSaveable { mutableStateOf("") }
var addTeamKey by rememberSaveable { mutableStateOf("") }
val addTeamOptions = spielplanTeamsBySeason[addSeason].orEmpty()
LaunchedEffect(spielplanSeasons) {
if (addSeason.isBlank() || spielplanSeasons.none { it.slug == addSeason }) {
addSeason = spielplanSeasons.firstOrNull()?.slug.orEmpty()
}
}
LaunchedEffect(addSeason, addTeamOptions) {
if (addTeamOptions.none { teamOptionKey(it) == addTeamKey }) {
addTeamKey = addTeamOptions.firstOrNull()?.let(::teamOptionKey).orEmpty()
}
}
Surface(
color = Color(0xFFFAFAFA),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedButton(onClick = onToggleEdit) {
Text(if (editEnabled) "Startseiten-Editor schließen" else "Startseite anpassen")
}
if (editEnabled) {
Text(
"Elemente ein-/ausblenden und Reihenfolge festlegen.",
style = MaterialTheme.typography.bodyMedium,
color = Accent700,
)
sections.forEachIndexed { index, section ->
val label = homeSectionLabels[section.id] ?: section.id
val sectionKey = homeSectionKey(section)
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
Text(section.id, style = MaterialTheme.typography.labelSmall, color = Accent500)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Anzeigen", color = Accent700, style = MaterialTheme.typography.labelSmall)
androidx.compose.material3.Checkbox(
checked = section.enabled,
onCheckedChange = { enabled -> onEnabledChange(sectionKey, enabled) },
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(onClick = { onMoveUp(sectionKey) }, enabled = index > 0) { Text("Hoch") }
OutlinedButton(onClick = { onMoveDown(sectionKey) }, enabled = index < sections.lastIndex) { Text("Runter") }
}
}
if (section.id == "spielplan_team") {
SpielplanWidgetConfigEditor(
section = section,
seasons = spielplanSeasons,
teamsBySeason = spielplanTeamsBySeason,
onUpdate = { season, teamName, teamAgeGroup ->
onUpdateSpielplanWidget(sectionKey, season, teamName, teamAgeGroup)
},
)
}
}
}
}
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Widget hinzufügen", style = MaterialTheme.typography.titleMedium, color = Accent900)
Text("Spielplan Mannschaft", style = MaterialTheme.typography.bodyMedium, color = Accent700)
SimpleSelector(
label = "Saison",
selected = spielplanSeasons.firstOrNull { it.slug == addSeason }?.let { formatSeasonLabel(it.slug) }
?: "Bitte wählen",
options = spielplanSeasons.map { season ->
SelectOption(
key = season.slug,
label = formatSeasonLabel(season.slug),
)
},
onSelect = { option ->
addSeason = option.key
},
)
SimpleSelector(
label = "Mannschaft",
selected = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey }?.label ?: "Bitte wählen",
options = addTeamOptions.map { team ->
SelectOption(
key = teamOptionKey(team),
label = team.label,
)
},
onSelect = { option ->
addTeamKey = option.key
},
)
Button(
onClick = {
val selectedTeam = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey } ?: return@Button
onAddSpielplanWidget(addSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
},
enabled = addSeason.isNotBlank() && addTeamKey.isNotBlank(),
) {
Text("Widget hinzufügen")
}
}
}
TextButton(onClick = onReset) {
Text("Auf Server-Standard zurücksetzen")
}
}
}
}
}
private data class SelectOption(
val key: String,
val label: String,
)
@Composable
private fun SimpleSelector(
label: String,
selected: String,
options: List<SelectOption>,
onSelect: (SelectOption) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
OutlinedButton(onClick = { expanded = true }, enabled = options.isNotEmpty()) {
Text(selected)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
onSelect(option)
expanded = false
},
)
}
}
}
}
@Composable
private fun SpielplanWidgetConfigEditor(
section: HomepageSectionDto,
seasons: List<SeasonDto>,
teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
onUpdate: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
) {
val selectedSeason = section.config?.season.orEmpty()
val selectedTeamName = section.config?.teamName.orEmpty()
val selectedTeamAgeGroup = section.config?.teamAgeGroup.orEmpty()
val teamOptions = teamsBySeason[selectedSeason].orEmpty()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SimpleSelector(
label = "Saison",
selected = seasons.firstOrNull { it.slug == selectedSeason }?.let { formatSeasonLabel(it.slug) } ?: "Bitte wählen",
options = seasons.map { season -> SelectOption(season.slug, formatSeasonLabel(season.slug)) },
onSelect = { option ->
val fallbackTeam = teamsBySeason[option.key].orEmpty().firstOrNull()
onUpdate(
option.key,
fallbackTeam?.teamName ?: "",
fallbackTeam?.teamAgeGroup ?: "",
)
},
)
SimpleSelector(
label = "Mannschaft",
selected = teamOptions.firstOrNull {
it.teamName == selectedTeamName && it.teamAgeGroup == selectedTeamAgeGroup
}?.label ?: "Bitte wählen",
options = teamOptions.map { team -> SelectOption(teamOptionKey(team), team.label) },
onSelect = { option ->
val selectedTeam = teamOptions.firstOrNull { teamOptionKey(it) == option.key }
if (selectedTeam != null) {
onUpdate(selectedSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
}
},
)
}
}
@Composable
private fun HomeSpielplanTeamWidgetSection(
section: HomepageSectionDto,
spiele: List<SpielDto>,
error: String?,
loading: Boolean,
onOpenAll: () -> Unit,
) {
val teamName = section.config?.teamName.orEmpty()
val teamAgeGroup = section.config?.teamAgeGroup.orEmpty()
val title = if (teamAgeGroup.contains("jugend", ignoreCase = true) && teamName.isNotBlank()) {
"Spielplan: (J) $teamName"
} else {
"Spielplan: ${teamName.ifBlank { "Mannschaft" }}"
}
val season = section.config?.season.orEmpty()
HomeSection(title = title, subtitle = "Saison ${formatSeasonLabel(season)}", background = Color.White) {
if (loading) {
LoadingRow("Spiele werden geladen...")
} else if (!error.isNullOrBlank()) {
EmptyRow(error)
} else if (spiele.isEmpty()) {
EmptyRow("Keine kommenden Spiele für diese Mannschaft gefunden.")
} else {
spiele.forEach { spiel -> MatchCard(spiel) }
}
PrimaryAction("Voller Spielplan", onOpenAll)
}
}
@Composable
private fun WebHero(imageUrl: String?) {
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 390.dp)
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
contentAlignment = Alignment.Center,
) {
if (!imageUrl.isNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.matchParentSize().alpha(0.10f),
contentScale = ContentScale.Crop,
)
}
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"Willkommen beim",
style = MaterialTheme.typography.displayLarge,
color = Accent900,
textAlign = TextAlign.Center,
)
Text(
"Harheimer TC",
style = MaterialTheme.typography.displayLarge.copy(fontSize = 40.sp),
fontWeight = FontWeight.Bold,
color = Primary600,
textAlign = TextAlign.Center,
)
Text(
"Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit $years Jahren",
style = MaterialTheme.typography.bodyLarge,
color = Accent700,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun HomeTermineSection(termine: List<TerminDto>, loading: Boolean, onAll: () -> Unit) {
HomeSection(title = "Kommende Termine", background = Color(0xFFFAFAFA)) {
if (loading) {
LoadingRow("Termine werden geladen...")
} else if (termine.isEmpty()) {
EmptyRow("Keine kommenden Termine")
} else {
termine.forEach { termin -> AppointmentCard(termin) }
}
PrimaryAction("Alle Termine anzeigen", onAll)
}
}
@Composable
private fun HomeGamesSection(spiele: List<SpielDto>, loading: Boolean, onAll: () -> Unit) {
HomeSection(title = "Nächste Spiele", background = Color.White) {
if (loading) {
LoadingRow("Spielplan wird geladen...")
} else if (spiele.isEmpty()) {
EmptyRow("Derzeit sind keine Spiele geplant.")
} else {
spiele.forEach { spiel -> MatchCard(spiel) }
}
PrimaryAction("Alle Spiele anzeigen", onAll)
}
}
@Composable
private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
HomeSection(
title = "Aktuelles",
subtitle = "Die neuesten Nachrichten aus unserem Verein",
background = Color.White,
) {
news.forEach { item ->
Surface(
color = Color(0xFFFAFAFA),
shape = RoundedCornerShape(12.dp),
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp).clickable { onOpen(item) },
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
RichText(item.content)
}
}
}
}
}
@Composable
private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
HomeSection(title = null, background = Color(0xFFFAFAFA)) {
ActionCard(
title = "Mitglied werden",
body = "Werden Sie Teil unserer Tischtennisfamilie und profitieren Sie von Training, Wettkämpfen und Gemeinschaft.",
action = "Mehr erfahren",
onClick = onMembership,
)
ActionCard(
title = "Kontakt aufnehmen",
body = "Haben Sie Fragen oder möchten ein kostenloses Probetraining vereinbaren? Wir freuen uns auf Ihre Nachricht!",
action = "Jetzt kontaktieren",
onClick = onContact,
)
}
}
@Composable
private fun HomeExtraActionSection(title: String, body: String, action: String, onClick: () -> Unit) {
HomeSection(title = null, background = Color.White) {
ActionCard(
title = title,
body = body,
action = action,
onClick = onClick,
)
}
}
@Composable
private fun HomeSection(
title: String?,
subtitle: String? = null,
background: Color,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().background(background).padding(horizontal = 18.dp, vertical = 38.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
title?.let {
Text(it, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
Spacer(Modifier.height(12.dp))
Box(Modifier.width(74.dp).height(4.dp).background(Primary600))
subtitle?.let { text ->
Spacer(Modifier.height(14.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, color = Accent500, textAlign = TextAlign.Center)
}
Spacer(Modifier.height(26.dp))
}
content()
}
}
@Composable
private fun AppointmentCard(termin: TerminDto) {
Surface(
shape = RoundedCornerShape(9.dp),
color = Color(0xFFF4F4F5),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Column(
modifier = Modifier.width(65.dp).padding(vertical = 8.dp, horizontal = 3.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(formatDate(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 19.sp)
Text(formatDate(termin.datum, "MMM yyyy"), color = Color.White, style = MaterialTheme.typography.labelSmall)
termin.uhrzeit?.let { Text("$it Uhr", color = Color.White, style = MaterialTheme.typography.labelSmall) }
}
}
Column(Modifier.weight(1f).padding(start = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(termin.titel, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
termin.beschreibung?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent700, maxLines = 2)
}
}
termin.kategorie?.takeIf { it.isNotBlank() }?.let {
Text(
it,
color = Accent700,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.background(Primary100, RoundedCornerShape(14.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
)
}
}
}
}
@Composable
private fun MatchCard(spiel: SpielDto) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatMatchDate(spiel.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
Text(spiel.termin.substringAfter(' ', "-"), style = MaterialTheme.typography.bodyMedium, color = Accent500)
}
Row(verticalAlignment = Alignment.CenterVertically) {
TeamLabel("Heim", spiel.heimMannschaft, Modifier.weight(1f))
Box(
Modifier.size(34.dp).background(Primary100, RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center,
) { Text("vs", color = Primary600, fontWeight = FontWeight.Bold) }
TeamLabel("Gast", spiel.gastMannschaft, Modifier.weight(1f), right = true)
}
spiel.runde?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent500)
}
}
}
}
@Composable
private fun TeamLabel(label: String, value: String, modifier: Modifier, right: Boolean = false) {
Column(modifier.padding(horizontal = 8.dp), horizontalAlignment = if (right) Alignment.End else Alignment.Start) {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(value, fontWeight = FontWeight.SemiBold, color = Accent900, textAlign = if (right) TextAlign.End else TextAlign.Start)
}
}
@Composable
private fun ActionCard(title: String, body: String, action: String, onClick: () -> Unit) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 15.dp).clickable(onClick = onClick),
) {
Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(48.dp).background(Primary100, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) { Text("HTC", color = Primary600, fontWeight = FontWeight.Bold, fontSize = 11.sp) }
Spacer(Modifier.width(14.dp))
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
}
Text(body, style = MaterialTheme.typography.bodyMedium, color = Accent700)
Text("$action >", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
private fun PrimaryAction(label: String, onClick: () -> Unit) {
Spacer(Modifier.height(16.dp))
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
shape = RoundedCornerShape(8.dp),
) { Text("$label >", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) }
}
@Composable
private fun EmptyRow(text: String) {
Surface(color = Accent100, shape = RoundedCornerShape(9.dp), modifier = Modifier.fillMaxWidth()) {
Text(text, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 30.dp, horizontal = 12.dp))
}
}
@Composable
private fun LoadingRow(text: String) {
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(28.dp), color = Primary600)
Spacer(Modifier.height(10.dp))
Text(text, color = Accent500)
}
}
@Composable
private fun HomeFooter() {
Column(
Modifier.fillMaxWidth().background(Accent900).padding(horizontal = 18.dp, vertical = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Harheimer TC", style = MaterialTheme.typography.titleLarge, color = Color.White)
Text("Tischtennis in Frankfurt-Harheim seit 1954", color = Accent200, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatDate(value: String, pattern: String): String = runCatching {
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
SimpleDateFormat(pattern, Locale.GERMANY).format(source.parse(value)!!)
}.getOrDefault(value)
private fun formatMatchDate(value: String): String = runCatching {
val source = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
val date = source.parse(value.substringBefore(' '))!!
SimpleDateFormat("EEE dd.MM.yyyy", Locale.GERMANY).format(date)
}.getOrDefault(value.substringBefore(' '))
private fun formatNewsDate(value: String?): String {
if (value.isNullOrBlank()) return ""
return runCatching {
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
}.getOrDefault(value.take(10))
}
private fun homeSectionKey(section: HomepageSectionDto): String =
section.key?.takeIf { it.isNotBlank() } ?: section.id
private fun teamOptionKey(option: HomeSpielplanTeamOption): String =
"${option.teamName}|${option.teamAgeGroup}"
private fun formatSeasonLabel(value: String): String {
val match = Regex("^(\\d{2})--(\\d{2})$").find(value)
if (match == null) return value.ifBlank { "-" }
return "20${match.groupValues[1]}/${match.groupValues[2]}"
}
private val homeSectionLabels = mapOf(
"banner" to "Banner (Willkommen)",
"aktuelles" to "Aktuelles",
"termine" to "Kommende Termine",
"spiele" to "Nächste Spiele",
"kontakt" to "Kontakt-Boxen",
"training" to "Training-Teaser",
"links" to "Links-Teaser",
"vereinsmeisterschaften" to "Vereinsmeisterschaften-Teaser",
"spielplan_team" to "Widget: Spielplan Mannschaft",
)

View File

@@ -0,0 +1,421 @@
package de.harheimertc.ui.screens.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.HomepageSectionConfigDto
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.HomeLayoutPreferences
import de.harheimertc.repositories.HomeRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class HomeSpielplanTeamOption(
val teamName: String,
val teamAgeGroup: String,
) {
val label: String
get() = if (teamAgeGroup.contains("jugend", ignoreCase = true)) {
"(J) $teamName"
} else {
teamName
}
}
data class HomeUiState(
val loading: Boolean = true,
val heroImageUrl: String? = null,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
val homepageSections: List<HomepageSectionDto> = defaultHomepageSections,
val spielplanSeasons: List<SeasonDto> = emptyList(),
val spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>> = emptyMap(),
val spielplanWidgetPreviews: Map<String, List<SpielDto>> = emptyMap(),
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false,
val error: Boolean = false,
val errorMessage: String? = null,
val debugDiagnostics: List<String> = emptyList(),
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: HomeRepository,
private val layoutPreferences: HomeLayoutPreferences,
) : ViewModel() {
private val _state = MutableStateFlow(HomeUiState())
val state: StateFlow<HomeUiState> = _state
private var serverSections: List<HomepageSectionDto> = defaultHomepageSections
private val seasonGamesCache = mutableMapOf<String, List<SpielDto>>()
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(
loading = true,
error = false,
errorMessage = null,
debugDiagnostics = emptyList(),
)
repository.fetchHomeData()
.onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections)
val sections = mergeWithUserSections(
server = serverSections,
user = layoutPreferences.getSections(),
)
seasonGamesCache.clear()
data.selectedSpielplanSeason?.takeIf { it.isNotBlank() }?.let { season ->
seasonGamesCache[season] = data.spiele
}
val widgetData = loadWidgetData(
sections = sections,
seasons = data.spielplanSeasons,
)
_state.value = HomeUiState(
loading = false,
heroImageUrl = data.heroImageUrl,
termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
.sortedBy { it.asDateTime() }
.take(3),
spiele = data.spiele
.filter { game ->
game.asDate()?.let { date ->
!date.isBefore(LocalDate.now()) &&
!date.isAfter(LocalDate.now().plusDays(7))
} == true
}
.sortedBy { it.asDate() }
.take(3),
news = data.news.take(3),
homepageSections = sections,
spielplanSeasons = widgetData.seasons,
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
debugDiagnostics = data.diagnostics,
)
}
.onFailure { err ->
_state.value = HomeUiState(
loading = false,
error = true,
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
)
}
}
}
fun moveSectionUp(sectionKey: String) {
updateSections { sections ->
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
if (index <= 0) return@updateSections sections
sections.toMutableList().also { list ->
val current = list.removeAt(index)
list.add(index - 1, current)
}
}
}
fun moveSectionDown(sectionKey: String) {
updateSections { sections ->
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
if (index < 0 || index >= sections.lastIndex) return@updateSections sections
sections.toMutableList().also { list ->
val current = list.removeAt(index)
list.add(index + 1, current)
}
}
}
fun setSectionEnabled(sectionKey: String, enabled: Boolean) {
updateSections { sections ->
sections.map { section ->
if (sectionKey(section) == sectionKey) section.copy(enabled = enabled) else section
}
}
}
fun addSpielplanTeamWidget(season: String, teamName: String, teamAgeGroup: String) {
val normalizedSeason = season.trim()
val normalizedTeamName = teamName.trim()
if (normalizedSeason.isBlank() || normalizedTeamName.isBlank()) return
val newSection = HomepageSectionDto(
id = WIDGET_SECTION_ID,
key = "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
enabled = true,
config = HomepageSectionConfigDto(
season = normalizedSeason,
teamName = normalizedTeamName,
teamAgeGroup = teamAgeGroup.trim(),
),
)
updateSections { sections -> sections + newSection }
refreshWidgetData()
}
fun updateSpielplanTeamWidget(
sectionKey: String,
season: String,
teamName: String,
teamAgeGroup: String,
) {
updateSections { sections ->
sections.map { section ->
if (sectionKey(section) != sectionKey) return@map section
section.copy(
config = HomepageSectionConfigDto(
season = season.trim(),
teamName = teamName.trim(),
teamAgeGroup = teamAgeGroup.trim(),
),
)
}
}
refreshWidgetData()
}
fun resetSections() {
val reset = serverSections
layoutPreferences.clearSections()
_state.value = _state.value.copy(homepageSections = reset)
refreshWidgetData()
}
private fun updateSections(transform: (List<HomepageSectionDto>) -> List<HomepageSectionDto>) {
val updated = transform(_state.value.homepageSections)
if (updated == _state.value.homepageSections) return
layoutPreferences.setSections(updated)
_state.value = _state.value.copy(homepageSections = updated)
}
private fun refreshWidgetData() {
viewModelScope.launch {
_state.value = _state.value.copy(widgetsLoading = true)
val widgetData = loadWidgetData(
sections = _state.value.homepageSections,
seasons = _state.value.spielplanSeasons,
)
_state.value = _state.value.copy(
spielplanSeasons = widgetData.seasons,
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
widgetsLoading = false,
)
}
}
private suspend fun loadWidgetData(
sections: List<HomepageSectionDto>,
seasons: List<SeasonDto>,
): HomeWidgetData {
val allSeasons = seasons
.filter { it.slug.isNotBlank() }
.distinctBy { it.slug }
.toMutableList()
seasonGamesCache.keys.forEach { slug ->
if (allSeasons.none { it.slug == slug }) {
allSeasons += SeasonDto(slug = slug, label = slug)
}
}
val neededWidgetSeasons = sections
.asSequence()
.filter { it.id == WIDGET_SECTION_ID }
.mapNotNull { it.config?.season?.takeIf(String::isNotBlank) }
.toSet()
allSeasons.forEach { season ->
ensureSeasonLoaded(season.slug)
}
neededWidgetSeasons.forEach { season ->
if (allSeasons.none { it.slug == season }) {
allSeasons += SeasonDto(slug = season, label = season)
}
ensureSeasonLoaded(season)
}
val teamsBySeason = buildMap {
allSeasons.forEach { season ->
val games = seasonGamesCache[season.slug] ?: return@forEach
put(season.slug, extractHarheimerTeams(games))
}
}
val previews = mutableMapOf<String, List<SpielDto>>()
val errors = mutableMapOf<String, String>()
sections.forEach { section ->
if (section.id != WIDGET_SECTION_ID) return@forEach
val key = sectionKey(section)
val config = section.config
val season = config?.season.orEmpty()
val teamName = config?.teamName.orEmpty()
if (season.isBlank() || teamName.isBlank()) {
errors[key] = "Bitte Saison und Mannschaft wählen."
previews[key] = emptyList()
return@forEach
}
val games = seasonGamesCache[season]
if (games == null) {
errors[key] = "Spielplan konnte nicht geladen werden."
previews[key] = emptyList()
return@forEach
}
previews[key] = filterUpcomingTeamGames(games, teamName, config?.teamAgeGroup.orEmpty())
}
return HomeWidgetData(
seasons = allSeasons,
teamsBySeason = teamsBySeason,
previewGamesBySectionKey = previews,
errorsBySectionKey = errors,
)
}
private suspend fun ensureSeasonLoaded(season: String) {
if (seasonGamesCache.containsKey(season)) return
repository.fetchSpielplanForSeason(season).onSuccess { response ->
seasonGamesCache[season] = response.data
}
}
}
private data class HomeWidgetData(
val seasons: List<SeasonDto>,
val teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
val previewGamesBySectionKey: Map<String, List<SpielDto>>,
val errorsBySectionKey: Map<String, String>,
)
private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}.getOrNull()
fun SpielDto.asDate(): LocalDate? = runCatching {
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}.getOrNull()
private val defaultHomepageSections = listOf(
HomepageSectionDto(id = "banner", key = "banner", enabled = true),
HomepageSectionDto(id = "aktuelles", key = "aktuelles", enabled = true),
HomepageSectionDto(id = "termine", key = "termine", enabled = true),
HomepageSectionDto(id = "spiele", key = "spiele", enabled = true),
HomepageSectionDto(id = "kontakt", key = "kontakt", enabled = true),
HomepageSectionDto(id = "training", key = "training", enabled = false),
HomepageSectionDto(id = "links", key = "links", enabled = false),
HomepageSectionDto(id = "vereinsmeisterschaften", key = "vereinsmeisterschaften", enabled = false),
)
private fun normalizedHomepageSections(configuredSections: List<HomepageSectionDto>): List<HomepageSectionDto> {
val configured = configuredSections
.filter { it.id.isNotBlank() }
.mapIndexed { index, section ->
val fallback = if (section.id == WIDGET_SECTION_ID) "${section.id}_${index + 1}" else section.id
section.copy(key = section.key?.takeIf { it.isNotBlank() } ?: fallback)
}
val knownIds = configured.map { it.id }.toMutableSet()
return buildList {
addAll(configured)
defaultHomepageSections.forEach { section ->
if (knownIds.add(section.id)) add(section)
}
}
}
private fun mergeWithUserSections(
server: List<HomepageSectionDto>,
user: List<HomepageSectionDto>?,
): List<HomepageSectionDto> {
if (user.isNullOrEmpty()) return server
val serverById = server.associateBy { it.id }
val serverByKey = server.associateBy { sectionKey(it) }
val ordered = buildList<HomepageSectionDto> {
user.forEach { userSection ->
val matchedServerSection = serverByKey[sectionKey(userSection)]
?: if (userSection.id == WIDGET_SECTION_ID) null else serverById[userSection.id]
if (matchedServerSection != null) {
if (none { sectionKey(it) == sectionKey(matchedServerSection) }) {
add(
matchedServerSection.copy(
enabled = userSection.enabled,
key = sectionKey(userSection),
config = userSection.config,
),
)
}
return@forEach
}
if (userSection.id == WIDGET_SECTION_ID && none { sectionKey(it) == sectionKey(userSection) }) {
add(
userSection.copy(
key = userSection.key?.takeIf { it.isNotBlank() }
?: "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
),
)
}
}
server.forEach { serverSection ->
if (none { sectionKey(it) == sectionKey(serverSection) }) add(serverSection)
}
}
return ordered.ifEmpty { server }
}
private fun sectionKey(section: HomepageSectionDto): String =
section.key?.takeIf { it.isNotBlank() } ?: section.id
private fun extractHarheimerTeams(games: List<SpielDto>): List<HomeSpielplanTeamOption> =
games
.flatMap { game ->
listOf(
HomeSpielplanTeamOption(game.heimMannschaft.trim(), game.heimAltersklasse.trim()),
HomeSpielplanTeamOption(game.gastMannschaft.trim(), game.gastAltersklasse.trim()),
)
}
.filter { option -> option.teamName.contains("Harheimer TC", ignoreCase = true) }
.filter { option -> option.teamName.isNotBlank() }
.distinctBy { "${it.teamName}|${it.teamAgeGroup}" }
.sortedBy { it.label }
private fun filterUpcomingTeamGames(
games: List<SpielDto>,
teamName: String,
teamAgeGroup: String,
): List<SpielDto> {
val normalizedTeam = teamName.trim()
val normalizedAgeGroup = teamAgeGroup.trim()
val today = LocalDate.now()
return games
.asSequence()
.filter { game ->
val homeMatch = game.heimMannschaft.trim() == normalizedTeam &&
(normalizedAgeGroup.isBlank() || game.heimAltersklasse.trim() == normalizedAgeGroup)
val awayMatch = game.gastMannschaft.trim() == normalizedTeam &&
(normalizedAgeGroup.isBlank() || game.gastAltersklasse.trim() == normalizedAgeGroup)
homeMatch || awayMatch
}
.filter { game -> game.asDate()?.let { !it.isBefore(today) } == true }
.sortedBy { it.asDate() }
.take(5)
.toList()
}
private const val WIDGET_SECTION_ID = "spielplan_team"

View File

@@ -0,0 +1,153 @@
package de.harheimertc.ui.screens.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
import de.harheimertc.ui.navigation.Destinations
@Composable
fun LoginScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(state.loggedIn, state.restoring) {
if (state.loggedIn && !state.restoring) {
navController.navigate(Destinations.MemberArea.route) {
launchSingleTop = true
popUpTo(Destinations.Login.route) {
inclusive = true
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Column(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
Text(
"Mitglieder-Login",
style = MaterialTheme.typography.displayLarge,
color = Accent900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
"Melden Sie sich an, um auf den Mitgliederbereich zuzugreifen.",
color = Accent500,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 9.dp),
)
}
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (state.restoring) {
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
Text("Sitzung wird geprüft...", color = Accent500)
} else if (!state.loggedIn) {
ValidatedTextField(
value = state.email,
onValueChange = viewModel::setEmail,
label = "E-Mail-Adresse",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
)
ValidatedTextField(
value = state.password,
onValueChange = viewModel::setPassword,
label = "Passwort",
error = state.fieldErrors["password"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
)
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
}
OutlinedButton(
onClick = { viewModel.passkeyLogin(context) },
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) {
Text("Mit Passkey anmelden")
}
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Passwort vergessen?")
}
TextButton(onClick = { navController.navigate(Destinations.Register.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Registrierung beantragen")
}
} else {
Text("Angemeldet", style = MaterialTheme.typography.titleLarge, color = Color(0xFF166534))
Text(state.userName.orEmpty(), color = Accent900)
if (state.roles.isNotEmpty()) Text(state.roles.joinToString(", "), color = Accent500)
OutlinedButton(onClick = viewModel::logout, modifier = Modifier.fillMaxWidth()) { Text("Abmelden") }
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.message?.let { Text(it, color = Color(0xFF166534)) }
}
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
Text(
"Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.",
color = Primary900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
}
}
}

View File

@@ -0,0 +1,121 @@
package de.harheimertc.ui.screens.login
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.PasskeyRepository
import de.harheimertc.ui.components.isValidEmail
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginUiState(
val email: String = "",
val password: String = "",
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = false,
val restoring: Boolean = true,
val loggedIn: Boolean = false,
val userName: String? = null,
val roles: List<String> = emptyList(),
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val repository: LoginRepository,
private val passkeyRepository: PasskeyRepository,
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state
init {
viewModelScope.launch {
repository.status()
.onSuccess { status ->
_state.value = _state.value.copy(
restoring = false,
loggedIn = status.isLoggedIn,
userName = status.user?.name ?: status.user?.email,
roles = status.roles.ifEmpty { status.user?.roles.orEmpty() },
)
}
.onFailure { _state.value = _state.value.copy(restoring = false) }
}
}
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
}
fun setPassword(value: String) {
_state.value = _state.value.copy(password = value, fieldErrors = _state.value.fieldErrors - "password", error = null)
}
fun login() {
val current = _state.value
val fieldErrors = buildMap {
if (!isValidEmail(current.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
if (current.password.isBlank()) put("password", "Bitte geben Sie Ihr Passwort ein.")
}
if (fieldErrors.isNotEmpty()) {
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
return
}
viewModelScope.launch {
_state.value = current.copy(loading = true, error = null, message = null)
repository.login(current.email, current.password)
.onSuccess { response ->
_state.value = current.copy(
password = "",
loading = false,
restoring = false,
loggedIn = true,
userName = response.user?.name ?: response.user?.email,
roles = response.user?.roles.orEmpty(),
message = "Anmeldung erfolgreich.",
)
}
.onFailure {
_state.value = current.copy(loading = false, error = it.message ?: "Anmeldung fehlgeschlagen.")
}
}
}
fun passkeyLogin(context: Context) {
val current = _state.value
viewModelScope.launch {
_state.value = current.copy(loading = true, error = null, message = null)
passkeyRepository.login(context, current.email)
.onSuccess { response ->
_state.value = current.copy(
password = "",
loading = false,
restoring = false,
loggedIn = true,
userName = response.user?.name ?: response.user?.email,
roles = response.user?.roles.orEmpty(),
message = "Passkey-Anmeldung erfolgreich.",
)
}
.onFailure {
_state.value = current.copy(
loading = false,
restoring = false,
error = it.message ?: "Passkey-Anmeldung fehlgeschlagen.",
)
}
}
}
fun logout() {
viewModelScope.launch {
repository.logout()
_state.value = LoginUiState(restoring = false, message = "Sie wurden abgemeldet.")
}
}
}

View File

@@ -0,0 +1,176 @@
package de.harheimertc.ui.screens.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun PasswordResetScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: PasswordResetViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
AuthFormPage(
title = "Passwort zurücksetzen",
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
ValidatedTextField(
value = state.email,
onValueChange = viewModel::setEmail,
label = "E-Mail-Adresse",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
)
MessageLines(state.error, state.message)
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
Text(if (state.loading) "Wird gesendet..." else "Passwort zurücksetzen")
}
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Zurück zum Login")
}
AuthNotice("Sie erhalten eine E-Mail mit einem Reset-Link, sofern ein Konto vorhanden ist. Ihr bisheriges Passwort bleibt bis zur Änderung gültig.")
}
}
@Composable
fun RegisterScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: RegisterViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val form = state.form
AuthFormPage(
title = "Registrierung",
subtitle = "Beantragen Sie einen Zugang zum Mitgliederbereich.",
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
ValidatedTextField(form.name, { viewModel.update(form.copy(name = it)) }, "Name *", error = state.fieldErrors["name"])
ValidatedTextField(
value = form.email,
onValueChange = { viewModel.update(form.copy(email = it)) },
label = "E-Mail-Adresse *",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
)
ValidatedTextField(
value = form.phone,
onValueChange = { viewModel.update(form.copy(phone = it)) },
label = "Telefon",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
)
ValidatedTextField(
value = form.birthDate,
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
label = "Geburtsdatum * (JJJJ-MM-TT)",
error = state.fieldErrors["birthDate"],
)
ValidatedTextField(
value = form.password,
onValueChange = { viewModel.update(form.copy(password = it)) },
label = "Passwort *",
error = state.fieldErrors["password"],
visualTransformation = PasswordVisualTransformation(),
)
ValidatedTextField(
value = form.passwordRepeat,
onValueChange = { viewModel.update(form.copy(passwordRepeat = it)) },
label = "Passwort wiederholen *",
error = state.fieldErrors["passwordRepeat"],
visualTransformation = PasswordVisualTransformation(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
Text("Geburtstag im Mitgliederbereich anzeigen")
}
MessageLines(state.error, state.message)
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
Text(if (state.loading) "Wird gesendet..." else "Registrierung beantragen")
}
AuthNotice("Ihre Registrierung muss vor der Anmeldung vom Vorstand freigegeben werden.")
}
}
@Composable
private fun AuthFormPage(
title: String,
subtitle: String,
onBack: () -> Unit,
showBackNavigation: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = onBack) { Text("< Login", color = Primary600, fontWeight = FontWeight.SemiBold) }
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 22.dp))
Text(subtitle, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 9.dp, bottom = 14.dp))
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp)) {
content()
}
}
}
}
}
@Composable
private fun MessageLines(error: String?, message: String?) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
message?.let { Text(it, color = Color(0xFF166534)) }
}
@Composable
private fun AuthNotice(text: String) {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Text(text, color = Primary900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(13.dp))
}
}

View File

@@ -0,0 +1,117 @@
package de.harheimertc.ui.screens.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RegistrationVisibility
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.ui.components.isValidEmail
import de.harheimertc.ui.components.isValidIsoDate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PasswordResetUiState(
val email: String = "",
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class PasswordResetViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(PasswordResetUiState())
val state: StateFlow<PasswordResetUiState> = _state
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
}
fun submit() {
val email = _state.value.email.trim()
if (!isValidEmail(email)) {
_state.value = _state.value.copy(
fieldErrors = mapOf("email" to "Bitte eine gültige E-Mail-Adresse eingeben."),
error = "Bitte prüfen Sie die markierten Felder.",
)
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, message = null)
repository.resetPassword(email)
.onSuccess { response ->
_state.value = PasswordResetUiState(message = response.message ?: "Anfrage wurde gesendet.")
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = "Anfrage konnte nicht gesendet werden.")
}
}
}
}
data class RegisterFormState(
val name: String = "",
val email: String = "",
val phone: String = "",
val birthDate: String = "",
val password: String = "",
val passwordRepeat: String = "",
val showBirthday: Boolean = true,
)
data class RegisterUiState(
val form: RegisterFormState = RegisterFormState(),
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class RegisterViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(RegisterUiState())
val state: StateFlow<RegisterUiState> = _state
fun update(form: RegisterFormState) {
_state.value = _state.value.copy(form = form, fieldErrors = validateFields(form, onlyTouched = true), error = null)
}
fun submit() {
val form = _state.value.form
val fieldErrors = validateFields(form)
if (fieldErrors.isNotEmpty()) {
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, message = null)
repository.register(
RegistrationRequest(
name = form.name.trim(),
email = form.email.trim(),
phone = form.phone.trim().takeIf(String::isNotBlank),
password = form.password,
geburtsdatum = form.birthDate.trim(),
visibility = RegistrationVisibility(showBirthday = form.showBirthday),
),
).onSuccess { response ->
_state.value = RegisterUiState(message = response.message ?: "Registrierung wurde eingereicht.")
}.onFailure {
_state.value = _state.value.copy(loading = false, error = it.message ?: "Registrierung fehlgeschlagen.")
}
}
}
private fun validateFields(form: RegisterFormState, onlyTouched: Boolean = false): Map<String, String> = buildMap {
fun shouldValidate(value: String) = !onlyTouched || value.isNotBlank()
if (!onlyTouched && form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
if (shouldValidate(form.email) && !isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
if (shouldValidate(form.birthDate) && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
if (shouldValidate(form.password) && form.password.length < 8) put("password", "Das Passwort muss mindestens 8 Zeichen lang sein.")
if (form.passwordRepeat.isNotBlank() && form.password != form.passwordRepeat) put("passwordRepeat", "Die Passwörter stimmen nicht überein.")
if (!onlyTouched && form.passwordRepeat.isBlank()) put("passwordRepeat", "Bitte wiederholen Sie das Passwort.")
}
}

View File

@@ -0,0 +1,444 @@
package de.harheimertc.ui.screens.mannschaften
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary700
@Composable
fun MannschaftenScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MannschaftenViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
BackLink(navController, showBackNavigation)
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
if (state.seasons.isNotEmpty()) {
SeasonSelector(
seasons = state.seasons,
selectedSeason = state.selectedSeason,
onSeasonSelected = viewModel::selectSeason,
modifier = Modifier.padding(top = 14.dp),
)
}
}
when {
state.seasonsLoading -> item { Loading() }
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
else -> items(state.teams) { team ->
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug, state.selectedSeason)) }
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Spielpläne & Ergebnisse", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften.", color = Accent500)
Button(
onClick = { navController.navigate(Destinations.Spielplan.route) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Zu den Spielplänen") }
}
}
}
}
}
@Composable
private fun SeasonSelector(
seasons: List<de.harheimertc.data.SeasonDto>,
selectedSeason: String,
onSeasonSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var open by remember { mutableStateOf(false) }
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
BoxWithConstraints {
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
Text("v")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
seasons.forEach { season ->
DropdownMenuItem(
text = { Text(season.label.ifBlank { season.slug }) },
onClick = {
open = false
onSeasonSelected(season.slug)
},
)
}
}
}
}
}
@Composable
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
Surface(
color = Color.White,
shape = RoundedCornerShape(8.dp),
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
) {
Column {
Column(Modifier.fillMaxWidth().background(Brush.horizontalGradient(listOf(Primary600, Primary700))).padding(16.dp)) {
Text(team.mannschaft, style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 4.dp))
}
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
TeamInfo("Staffelleiter", team.staffelleiter)
TeamInfo("Heimspieltag", team.heimspieltag)
TeamInfo("Spielsystem", team.spielsystem)
Text("${team.spieler.size} Spieler - Details anzeigen", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
fun MannschaftDetailScreen(
slug: String,
season: String?,
navController: NavController,
showBackNavigation: Boolean,
viewModel: MannschaftDetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedTab by rememberSaveable(slug, season) { mutableStateOf(DetailTab.Matches) }
LaunchedEffect(slug, season) { viewModel.load(slug, season) }
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { BackLink(navController, showBackNavigation) }
if (state.loading) {
item { Loading() }
} else {
state.team?.let { team ->
item { TeamHeader(team) }
item {
InfoCard("Liga-Informationen") {
TeamInfo("Staffelleiter", team.staffelleiter)
TeamInfo("Telefon", team.telefon)
TeamInfo("Heimspieltag", team.heimspieltag)
TeamInfo("Spielsystem", team.spielsystem)
}
}
item {
InfoCard("Mannschaftsaufstellung") {
team.spieler.forEach { player ->
val captain = player == team.mannschaftsfuehrer
Surface(color = if (captain) Primary100 else Accent100, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(player, color = Accent900)
if (captain) Text("Mannschaftsführer", color = Primary600, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
item {
SchedulePanelHeader(
season = state.season,
selectedTab = selectedTab,
hasTable = team.informationenLink.isNotBlank(),
onSelected = { selectedTab = it },
)
}
when (selectedTab) {
DetailTab.Matches -> {
if (state.matchesError != null) item { Text(state.matchesError.orEmpty(), color = Primary700) }
if (state.matches.isEmpty() && state.matchesError == null) {
item { Text("Für diese Mannschaft sind aktuell keine Spiele vorhanden.", color = Accent500) }
} else {
items(state.matches) { MatchCard(it) }
}
}
DetailTab.Table -> {
when {
state.tableLoading -> item { Loading() }
state.tableError != null -> item { Text(state.tableError.orEmpty(), color = Primary700) }
state.tableRows.isEmpty() -> item { Text("Für diese Mannschaft ist aktuell keine Tabelle hinterlegt.", color = Accent500) }
else -> {
item { TableLegend() }
items(state.tableRows) { TableRow(it) }
}
}
}
}
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug, season) } }
}
}
}
private enum class DetailTab { Matches, Table }
@Composable
private fun SchedulePanelHeader(
season: String?,
selectedTab: DetailTab,
hasTable: Boolean,
onSelected: (DetailTab) -> Unit,
) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Aktueller Spielplan", style = MaterialTheme.typography.titleLarge, color = Accent900)
season?.let { Text("Saison ${seasonLabel(it)}", color = Accent500) }
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
DetailTabButton("Matches", selectedTab == DetailTab.Matches) { onSelected(DetailTab.Matches) }
if (hasTable) {
DetailTabButton("Tabelle", selectedTab == DetailTab.Table) { onSelected(DetailTab.Table) }
}
}
}
}
}
@Composable
private fun DetailTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
color = if (selected) Color.White else Accent100,
shape = RoundedCornerShape(6.dp),
shadowElevation = if (selected) 2.dp else 0.dp,
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = if (selected) Primary700 else Accent500,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 15.dp, vertical = 9.dp),
)
}
}
@Composable
private fun TeamHeader(team: Mannschaft) {
Column(Modifier.fillMaxWidth().background(Primary600, RoundedCornerShape(8.dp)).padding(20.dp)) {
Text(team.mannschaft, style = MaterialTheme.typography.displayLarge, color = Color.White)
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 5.dp))
}
}
@Composable
private fun InfoCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun TeamInfo(label: String, value: String) {
Row(Modifier.fillMaxWidth()) {
Text("$label:", color = Accent500, modifier = Modifier.weight(0.38f))
Text(value.ifBlank { "-" }, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.62f))
}
}
@Composable
private fun MatchCard(game: SpielDto) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 1.dp) {
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(game.termin, color = Accent900, fontWeight = FontWeight.SemiBold)
val result = if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) "${game.spieleHeim}:${game.spieleGast}" else "-"
Text(result, color = Primary600, fontWeight = FontWeight.SemiBold)
}
Text("${game.heimMannschaft} - ${game.gastMannschaft}", color = Accent900)
Text("${game.altersklasse} / ${game.staffel.removePrefix("E")}", color = Accent500, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun TableLegend() {
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
val compact = maxWidth < 560.dp
if (compact) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Platz / Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall)
TableMetricsHeader()
}
} else {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Platz", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(55.dp))
Text("Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f))
TableMetricsHeader()
}
}
}
}
}
@Composable
private fun TableRow(row: LeagueTableRowDto) {
val ourTeam = row.teamName.contains("Harheimer TC", ignoreCase = true)
Surface(
color = if (ourTeam) Primary100 else Color.White,
shape = RoundedCornerShape(8.dp),
shadowElevation = 1.dp,
) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
val compact = maxWidth < 560.dp
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
if (compact) {
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold)
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
Movement(row.movement)
}
TableMetrics(row)
} else {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(55.dp))
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
Movement(row.movement)
}
TableStanding(row)
}
}
Text(
"Sätze ${formatSets(row)} Bälle ${formatGames(row)}",
color = Accent500,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@Composable
private fun Movement(value: String?) {
when (value) {
"rise" -> Text("", color = Color(0xFF15803D))
"fall" -> Text("", color = Primary700)
}
}
@Composable
private fun TableMetricsHeader() {
Row(verticalAlignment = Alignment.CenterVertically) {
TableCell("Sp.", 48.dp, Accent500)
TableCell("S", 38.dp, Accent500)
TableCell("U", 38.dp, Accent500)
TableCell("N", 38.dp, Accent500)
TableCell("Punkte", 66.dp, Accent500)
}
}
@Composable
private fun TableStanding(row: LeagueTableRowDto) = TableMetrics(row)
@Composable
private fun TableMetrics(row: LeagueTableRowDto) {
Row(verticalAlignment = Alignment.CenterVertically) {
TableCell((row.meetings ?: "-").toString(), 48.dp, Accent900)
TableCell((row.won ?: 0).toString(), 38.dp, Accent900)
TableCell((row.tied ?: 0).toString(), 38.dp, Accent900)
TableCell((row.lost ?: 0).toString(), 38.dp, Accent900)
TableCell(formatPoints(row), 66.dp, Accent900)
}
}
@Composable
private fun TableCell(value: String, width: androidx.compose.ui.unit.Dp, color: Color) {
Text(
value,
color = color,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(width),
)
}
@Composable
private fun BackLink(navController: NavController, visible: Boolean) {
if (visible) TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
}
@Composable
private fun Loading() {
LoadingState("Mannschaftsdaten werden geladen...")
}
@Composable
private fun ErrorPanel(message: String, retry: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message, color = Primary700)
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
}
}
private fun seasonLabel(value: String): String =
Regex("^(\\d{2})--(\\d{2})$").matchEntire(value)?.let { "20${it.groupValues[1]}/${it.groupValues[2]}" } ?: value
private fun formatSets(row: LeagueTableRowDto): String = "${row.setsWon ?: 0}:${row.setsLost ?: 0}"
private fun formatGames(row: LeagueTableRowDto): String = "${row.gamesWon ?: 0}:${row.gamesLost ?: 0}"
private fun formatPoints(row: LeagueTableRowDto): String = "${row.pointsWon ?: 0}:${row.pointsLost ?: 0}"

View File

@@ -0,0 +1,205 @@
package de.harheimertc.ui.screens.mannschaften
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.SpielplanRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MannschaftenUiState(
val loading: Boolean = true,
val error: String? = null,
val teams: List<Mannschaft> = emptyList(),
val seasons: List<SeasonDto> = emptyList(),
val selectedSeason: String = "",
val seasonsLoading: Boolean = false,
)
@HiltViewModel
class MannschaftenViewModel @Inject constructor(
private val repository: MannschaftenRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MannschaftenUiState())
val state: StateFlow<MannschaftenUiState> = _state
init {
loadSeasonsAndMannschaften()
}
fun load() {
viewModelScope.launch {
val season = _state.value.selectedSeason.ifBlank { null }
_state.value = _state.value.copy(loading = true, error = null)
repository.fetchMannschaften(season)
.onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) }
.onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
}
}
fun selectSeason(season: String) {
if (season == _state.value.selectedSeason) return
_state.value = _state.value.copy(selectedSeason = season)
load()
}
private fun loadSeasonsAndMannschaften() {
viewModelScope.launch {
_state.value = _state.value.copy(seasonsLoading = true, error = null)
repository.fetchSeasons()
.onSuccess { response ->
val currentSeason = getCurrentSeasonSlug()
val seasons = response.seasons
.map { season -> SeasonDto(slug = season, label = formatSeasonLabel(season)) }
.ifEmpty {
val fallbackSeason = response.currentSeason.ifBlank { currentSeason }
listOf(SeasonDto(slug = fallbackSeason, label = formatSeasonLabel(fallbackSeason)))
}
val serverCurrentSeason = response.currentSeason.ifBlank { currentSeason }
val selectedSeason = when {
seasons.any { it.slug == currentSeason } -> currentSeason
seasons.any { it.slug == serverCurrentSeason } -> serverCurrentSeason
response.defaultSeason.isNotBlank() -> response.defaultSeason
seasons.isNotEmpty() -> seasons.first().slug
else -> currentSeason
}
_state.value = _state.value.copy(
seasonsLoading = false,
seasons = seasons,
selectedSeason = selectedSeason,
)
load()
}
.onFailure {
val currentSeason = getCurrentSeasonSlug()
_state.value = _state.value.copy(
seasonsLoading = false,
seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))),
selectedSeason = currentSeason,
)
load()
}
}
}
private fun getCurrentSeasonSlug(): String {
val now = java.util.Calendar.getInstance()
val year = now.get(java.util.Calendar.YEAR)
val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1
val endYear = startYear + 1
return "%02d--%02d".format(startYear % 100, endYear % 100)
}
private fun formatSeasonLabel(seasonSlug: String): String {
val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug
return "20${match.groupValues[1]}/${match.groupValues[2]}"
}
}
data class MannschaftDetailUiState(
val loading: Boolean = true,
val matchesError: String? = null,
val team: Mannschaft? = null,
val matches: List<SpielDto> = emptyList(),
val season: String? = null,
val tableLoading: Boolean = false,
val tableError: String? = null,
val tableRows: List<LeagueTableRowDto> = emptyList(),
)
@HiltViewModel
class MannschaftDetailViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository,
private val spielplanRepository: SpielplanRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MannschaftDetailUiState())
val state: StateFlow<MannschaftDetailUiState> = _state
private var loadedKey: String? = null
fun load(slug: String, season: String? = null) {
val selectedSeason = season?.takeIf { it.isNotBlank() }
val key = "$slug|${selectedSeason.orEmpty()}"
if (loadedKey == key) return
loadedKey = key
viewModelScope.launch {
_state.value = MannschaftDetailUiState(loading = true, season = selectedSeason)
val team = mannschaftenRepository.fetchMannschaften(selectedSeason).getOrDefault(emptyList()).find { it.slug == slug }
if (team == null) {
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
return@launch
}
spielplanRepository.fetchSpielplan(selectedSeason)
.onSuccess { plan ->
_state.value = MannschaftDetailUiState(
loading = false,
team = team,
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
season = plan.season ?: selectedSeason,
)
if (team.informationenLink.isNotBlank()) {
loadTable(team, plan.season ?: selectedSeason)
}
}
.onFailure {
_state.value = MannschaftDetailUiState(
loading = false,
team = team,
season = selectedSeason,
matchesError = "Der Spielplan konnte nicht geladen werden.",
)
}
}
}
private suspend fun loadTable(team: Mannschaft, season: String?) {
_state.value = _state.value.copy(tableLoading = true, tableError = null)
spielplanRepository.fetchTeamTable(team.mannschaft, season)
.onSuccess { response ->
_state.value = _state.value.copy(
tableLoading = false,
tableRows = response.table?.table?.leagueTable.orEmpty(),
)
}
.onFailure {
_state.value = _state.value.copy(
tableLoading = false,
tableError = "Tabelle konnte nicht geladen werden.",
tableRows = emptyList(),
)
}
}
private fun matchesTeam(game: SpielDto, cmsName: String): Boolean {
val variant = when (cmsName) {
"Erwachsene 1" -> "harheimer tc"
"Erwachsene 2" -> "harheimer tc ii"
"Erwachsene 3" -> "harheimer tc iii"
"Erwachsene 4" -> "harheimer tc iv"
"Erwachsene 5" -> "harheimer tc v"
"Jugendmannschaft", "Jugend I" -> "harheimer tc"
else -> return false
}
fun exact(value: String): Boolean =
if (variant == "harheimer tc") {
value == variant || (value.startsWith("$variant ") && !Regex("harheimer tc\\s+[ivx]+").containsMatchIn(value))
} else value == variant || value.startsWith("$variant ")
val home = game.heimMannschaft.lowercase()
val away = game.gastMannschaft.lowercase()
if (!exact(home) && !exact(away)) return false
return if (cmsName.startsWith("Erwachsene")) {
(exact(home) && game.heimAltersklasse.contains("Erwachsene", true)) ||
(exact(away) && game.gastAltersklasse.contains("Erwachsene", true))
} else {
game.heimAltersklasse.contains("Jugend", true) || game.gastAltersklasse.contains("Jugend", true) ||
home.contains("jugend") || away.contains("jugend")
}
}
}

View File

@@ -0,0 +1,495 @@
package de.harheimertc.ui.screens.memberarea
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import android.util.Log
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun MembersScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MembersViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val query = state.query.trim()
var sortAsc by remember { mutableStateOf(true) }
var sortField by remember { mutableStateOf("Nachname") }
val filtered = state.members.filter { member ->
query.isBlank() ||
member.name.contains(query, ignoreCase = true) ||
member.email.orEmpty().contains(query, ignoreCase = true)
}
// helpers
fun dayMonthKey(m: MemberDto): Int {
val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE
val s = src.trim()
try {
if (s.matches(Regex("^\\d{4}[-./].*"))) {
val ld = java.time.LocalDate.parse(s)
return ld.monthValue * 100 + ld.dayOfMonth
}
} catch (_: Exception) { }
// fallback: handle ISO without year (MM-DD or M-D), or German (DD.MM(.YYYY))
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
isoNoYear.find(s)?.let {
val (mo, d) = it.destructured
return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE }
}
german.find(s)?.let {
val (d, mo, _) = it.destructured
return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE }
}
val r = Regex("(\\d{1,2})\\D+(\\d{1,2})")
val match = r.find(s) ?: return Int.MAX_VALUE
val (a, b) = match.destructured
return try {
if (a.toInt() > 12) b.toInt() * 100 + a.toInt() else a.toInt() * 100 + b.toInt()
} catch (_: Exception) { Int.MAX_VALUE }
}
fun ageKey(m: MemberDto): Int {
val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE
val s = src.trim()
try {
if (s.matches(Regex("^\\d{4}[-./].*"))) {
val ld = java.time.LocalDate.parse(s)
val current = java.time.LocalDate.now().year
return current - ld.year
}
} catch (_: Exception) { }
val germanYear = Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2,4})$")
germanYear.find(s)?.let {
val yearStr = it.groupValues[3]
return try { java.time.LocalDate.now().year - yearStr.toInt() } catch (_: Exception) { Int.MAX_VALUE }
}
return Int.MAX_VALUE
}
val members = when (sortField) {
"Vorname" -> filtered.sortedWith(compareBy({ it.firstName.ifBlank { it.name } }, { it.lastName }))
"Geburtstag" -> filtered.sortedWith(compareBy({ dayMonthKey(it) }, { it.lastName }))
"Alter" -> filtered.sortedWith(compareBy({ ageKey(it) }, { it.lastName }))
else -> filtered.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName })
}.let { if (sortAsc) it else it.asReversed() }
var viewMode by remember { mutableStateOf("cards") }
var onlyHallKey by remember { mutableStateOf(false) }
val display = remember(members, onlyHallKey) { if (!onlyHallKey) members else members.filter { it.hasHallKey } }
Log.i("MembersScreen", "viewMode=$viewMode displayCount=${display.size}")
MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") {
item {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::updateQuery,
label = { Text("Suchen") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Sortieren nach", color = Accent700)
var expanded by remember { mutableStateOf(false) }
TextButton(onClick = { expanded = true }) { Text(sortField) }
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
listOf("Nachname", "Vorname", "Geburtstag", "Alter").forEach { opt ->
androidx.compose.material3.DropdownMenuItem(text = { Text(opt) }, onClick = { sortField = opt; expanded = false })
}
}
TextButton(onClick = { sortAsc = !sortAsc }) { Text(if (sortAsc) "A→Z" else "Z→A") }
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewMode = if (viewMode == "cards") "table" else "cards" }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF3F4F6))) {
Text(if (viewMode == "cards") "Tabelle" else "Karten", color = Accent900)
}
androidx.compose.material3.Checkbox(checked = onlyHallKey, onCheckedChange = { onlyHallKey = it })
Text("Nur mit Hallenschlüssel", color = Accent700)
}
}
item {
if (viewMode == "table") {
Text("DEBUG: TABLE", color = Color.Red, modifier = Modifier.fillMaxWidth().padding(8.dp))
}
}
when {
state.loading -> item { LoadingState("Mitglieder werden geladen...") }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
else -> if (viewMode == "table") {
items(display.size) { index ->
val m = display[index]
Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Column(Modifier.weight(1f)) {
Text(
m.name,
color = Accent900,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.ExtraBold,
)
}
Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) }
Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) }
}
}
}
} else {
items(display.size) { index -> MemberCard(display[index]) }
}
}
}
}
@Composable
fun MemberNewsScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MemberNewsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") {
when {
state.loading -> item { LoadingState("News werden geladen...") }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) }
else -> items(state.news.size) { index -> NewsCard(state.news[index]) }
}
}
}
@Composable
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
val groups = listOf(
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"),
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/mitgliederbereich/qttr", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
"CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"),
)
MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") {
item {
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
Text(
"Android nutzt Authorization: Bearer <Access-Token>. Abgelaufene Tokens werden über /api/auth/refresh automatisch erneuert.",
color = Primary900,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
}
items(groups.size) { index ->
val (title, endpoints) = groups[index]
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
endpoints.forEach { endpoint ->
Surface(color = Accent100, shape = RoundedCornerShape(8.dp)) {
Text(endpoint, color = Accent900, modifier = Modifier.fillMaxWidth().padding(10.dp))
}
}
}
}
}
}
}
@Composable
fun QttrScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: QttrViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val uriHandler = LocalUriHandler.current
val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021"
MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") {
item {
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900)
TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") }
}
}
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(state.title ?: "Andro-Rangliste", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("${state.rows.size} Einträge · Aktualisiert ${state.importedAt ?: "unbekannt"}", color = Accent500)
}
}
}
when {
state.loading -> item { LoadingState("QTTR-Werte werden geladen...") }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
}
}
}
@Composable
private fun MemberAreaPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
subtitle: String,
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text(title, style = MaterialTheme.typography.headlineMedium, color = Accent900)
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
content()
}
}
@Composable
private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
Text(
member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() },
style = MaterialTheme.typography.titleLarge,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.ExtraBold,
color = Accent900,
)
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
if (!member.birthday.isNullOrBlank()) {
val display = formatDayMonth(member.birthday) ?: member.birthday
Text("Geburtstag: $display", color = Accent500)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Badge(if (member.hasLogin) "Login" else member.source.ifBlank { "Mitglied" })
if (member.isMannschaftsspieler) Badge("Mannschaft")
if (member.hasHallKey) Badge("Hallenschlüssel")
}
if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) {
Text("Kontaktdaten sind für dich nicht freigegeben.", color = Accent500)
}
Row {
if (member.editable) {
TextButton(onClick = { onEdit(member) }) { Text("Bearbeiten") }
TextButton(onClick = { onDelete(member) }) { Text("Löschen") }
}
}
}
}
}
@Composable
private fun NewsCard(item: NewsDto) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(listOfNotNull(item.author, item.created).joinToString(" | "), color = Accent500)
if (item.isPublic || item.isHidden) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (item.isPublic) Badge("Öffentlich")
if (item.isHidden) Badge("Ausgeblendet")
}
}
RichText(item.content)
}
}
}
@Composable
private fun Badge(label: String) {
Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) {
Text(label, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp))
}
}
@Composable
private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
Surface(
color = if (highlighted) Primary100 else Color.White,
shape = RoundedCornerShape(14.dp),
shadowElevation = 3.dp,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(color = if (highlighted) Primary100 else Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(46.dp)) {
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(row.rank?.toString() ?: "-", color = Accent900, fontWeight = FontWeight.Bold)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
row.playerName.ifBlank { "Unbekannt" },
style = MaterialTheme.typography.titleMedium,
color = qttrNameColor(row.gender, isMinor(row.birthdate)),
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
)
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
}
Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
}
}
private fun isOwnRow(playerName: String?, currentUserName: String): Boolean {
fun normalize(value: String?): String {
return java.text.Normalizer.normalize(value.orEmpty().trim().lowercase(), java.text.Normalizer.Form.NFKD)
.replace(Regex("[\\u0300-\\u036f]"), "")
.replace(Regex("['`]"), "")
.replace(Regex("\\s+"), " ")
}
val current = normalize(currentUserName)
if (current.isBlank()) return false
return normalize(playerName) == current
}
private fun qttrNameColor(gender: String?, isMinor: Boolean): Color {
val value = gender.orEmpty().trim().lowercase()
return when {
value.startsWith('m') || value.contains("männ") || value.contains("maenn") -> if (isMinor) Color(0xFF60A5FA) else Color(0xFF2563EB)
value.startsWith('w') || value.contains("weib") || value.contains("frau") -> if (isMinor) Color(0xFFF9A8D4) else Color(0xFF9D174D)
else -> Accent900
}
}
private fun isMinor(birthdate: String?): Boolean {
val date = parseBirthdate(birthdate) ?: return false
val today = java.time.LocalDate.now()
var age = today.year - date.year
if (today.monthValue < date.monthValue || (today.monthValue == date.monthValue && today.dayOfMonth < date.dayOfMonth)) {
age -= 1
}
return age < 18
}
private fun parseBirthdate(value: String?): java.time.LocalDate? {
val raw = value.orEmpty().trim()
if (raw.isBlank()) return null
return try {
if (Regex("^\\d{4}$").matches(raw)) {
java.time.LocalDate.of(raw.toInt(), 1, 1)
} else {
java.time.LocalDate.parse(raw)
}
} catch (_: Exception) {
null
}
}
@Composable
private fun ErrorCard(message: String, onRetry: () -> Unit) {
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message, color = Color(0xFF991B1B))
Button(onClick = onRetry) { Text("Erneut laden") }
}
}
}
private fun normalizeToIso(srcRaw: String?): String? {
val src = srcRaw?.trim() ?: return null
try {
if (src.matches(Regex("^\\d{4}[-./].*"))) {
val ld = java.time.LocalDate.parse(src)
return String.format("%04d-%02d-%02d", ld.year, ld.monthValue, ld.dayOfMonth)
}
} catch (_: Exception) { }
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
isoNoYear.find(src)?.let {
val (mo, d) = it.destructured
return String.format("%02d-%02d", mo.toInt(), d.toInt())
}
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
german.find(src)?.let {
val (d, mo, y) = it.destructured
return if (y.isNullOrBlank()) String.format("%02d-%02d", mo.toInt(), d.toInt()) else String.format("%04d-%02d-%02d", y.toInt(), mo.toInt(), d.toInt())
}
return null
}
private fun formatDayMonth(srcRaw: String?): String? {
val src = srcRaw?.trim() ?: return null
try {
if (src.matches(Regex("^\\d{4}[-./].*"))) {
val ld = java.time.LocalDate.parse(src)
return String.format("%02d.%02d.", ld.dayOfMonth, ld.monthValue)
}
} catch (_: Exception) { }
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
isoNoYear.find(src)?.let {
val (mo, d) = it.destructured
return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null }
}
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
german.find(src)?.let {
val (d, mo, _) = it.destructured
return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null }
}
val r = Regex("(\\d{1,2})\\D+(\\d{1,2})")
val match = r.find(src) ?: return null
val (a, b) = match.destructured
return try { String.format("%02d.%02d.", a.toInt(), b.toInt()) } catch (_: Exception) { null }
}

View File

@@ -0,0 +1,179 @@
package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.repositories.MemberAreaRepository
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MembersUiState(
val members: List<MemberDto> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
val query: String = "",
)
@HiltViewModel
class MembersViewModel @Inject constructor(
private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() {
private val _state = MutableStateFlow(MembersUiState())
val state: StateFlow<MembersUiState> = _state
init {
load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
}
fun updateQuery(query: String) {
_state.value = _state.value.copy(query = query)
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
repository.members()
.onSuccess { response -> _state.value = _state.value.copy(members = response.members, loading = false) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Mitglieder konnten nicht geladen werden.") }
}
}
fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest) {
viewModelScope.launch {
repository.saveMember(request)
.onSuccess { _ -> load() }
.onFailure { /* expose errors if needed */ }
}
}
fun deleteMember(id: String) {
viewModelScope.launch {
repository.deleteMember(id)
.onSuccess { _ -> load() }
.onFailure { /* handle error */ }
}
}
fun bulkImport(members: List<Map<String, String>>) {
viewModelScope.launch {
repository.bulkImport(members)
.onSuccess { _ -> load() }
.onFailure { /* handle error */ }
}
}
fun toggleMannschaftsspieler(memberId: String) {
viewModelScope.launch {
repository.toggleMannschaftsspieler(memberId)
.onSuccess { _ -> load() }
.onFailure { /* handle error */ }
}
}
}
data class MemberNewsUiState(
val news: List<NewsDto> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
)
@HiltViewModel
class MemberNewsViewModel @Inject constructor(
private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() {
private val _state = MutableStateFlow(MemberNewsUiState())
val state: StateFlow<MemberNewsUiState> = _state
init {
load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
repository.news()
.onSuccess { response -> _state.value = _state.value.copy(news = response.news, loading = false) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "News konnten nicht geladen werden.") }
}
}
}
data class QttrUiState(
val rows: List<QttrRowDto> = emptyList(),
val title: String? = null,
val importedAt: String? = null,
val currentUserName: String = "",
val loading: Boolean = true,
val error: String? = null,
)
@HiltViewModel
class QttrViewModel @Inject constructor(
private val repository: MemberAreaRepository,
private val loginRepository: LoginRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() {
private val _state = MutableStateFlow(QttrUiState())
val state: StateFlow<QttrUiState> = _state
init {
load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
val authStatus = loginRepository.status().getOrDefault(AuthStatusResponse())
repository.qttrValues()
.onSuccess { response ->
_state.value = _state.value.copy(
rows = response.rows,
title = response.title,
importedAt = response.importedAt,
currentUserName = authStatus.user?.name.orEmpty(),
loading = false,
)
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = it.message ?: "QTTR-Werte konnten nicht geladen werden.")
}
}
}
}

View File

@@ -0,0 +1,220 @@
package de.harheimertc.ui.screens.memberarea
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.BuildConfig
import de.harheimertc.data.BirthdayDto
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
@Composable
fun MemberAreaScreen(
navController: NavController,
showBackNavigation: Boolean,
navigationState: NavigationUiState = NavigationUiState(),
viewModel: MemberAreaViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Mitgliederbereich", style = MaterialTheme.typography.displayLarge, color = Accent900)
Text("Alles Wichtige für Vereinsmitglieder.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
item {
MemberAreaCardGrid(navController)
}
if (navigationState.isAdmin) {
item {
ServerInfoCard()
}
}
item {
BirthdayCard(
birthdays = state.birthdays,
loading = state.loadingBirthdays,
error = state.birthdayError,
onRetry = viewModel::loadBirthdays,
)
}
}
}
@Composable
private fun ServerInfoCard() {
Surface(color = Primary100, shape = RoundedCornerShape(14.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Serververbindung", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(BuildConfig.API_BASE_URL.trimEnd('/'), color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
private fun MemberAreaCardGrid(navController: NavController) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
MemberAreaCard(
title = "Mein Profil",
description = "Persönliche Daten und Passwort verwalten",
marker = "P",
onClick = { navController.navigate(Destinations.Profile.route) },
)
MemberAreaCard(
title = "Benachrichtigungen",
description = "Persönliche Hinweise im Android-System verwalten",
marker = "B",
onClick = { navController.navigate(Destinations.NotificationSettings.route) },
)
MemberAreaCard(
title = "Mitglieder",
description = "Kontaktdaten der Vereinsmitglieder",
marker = "M",
onClick = { navController.navigate(Destinations.Members.route) },
)
MemberAreaCard(
title = "News",
description = "Neuigkeiten und Ankündigungen",
marker = "N",
onClick = { navController.navigate(Destinations.MemberNews.route) },
)
MemberAreaCard(
title = "QTTR",
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
marker = "Q",
onClick = { navController.navigate(Destinations.Qttr.route) },
)
}
}
@Composable
private fun MemberAreaCard(title: String, description: String, marker: String, onClick: () -> Unit) {
Surface(
color = Color.White,
shape = RoundedCornerShape(14.dp),
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(color = Primary100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(marker, color = Primary600, fontWeight = FontWeight.Bold)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(description, color = Accent700, modifier = Modifier.padding(top = 4.dp))
}
}
}
}
@Composable
private fun BirthdayCard(
birthdays: List<BirthdayDto>,
loading: Boolean,
error: String?,
onRetry: () -> Unit,
) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(color = Color(0xFFFCE7F3), shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("G", color = Color(0xFFDB2777), fontWeight = FontWeight.Bold)
}
}
Text("Geburtstage (nächste 4 Wochen)", style = MaterialTheme.typography.titleLarge, color = Accent900)
}
when {
loading -> {
LoadingState("Geburtstage werden geladen...")
}
error != null -> {
Text(error, color = MaterialTheme.colorScheme.error)
TextButton(onClick = onRetry) { Text("Erneut laden") }
}
birthdays.isEmpty() -> Text("Keine Geburtstage in den nächsten 4 Wochen.", color = Accent700)
else -> birthdays.forEach { birthday -> BirthdayRow(birthday) }
}
}
}
}
@Composable
private fun BirthdayRow(birthday: BirthdayDto) {
Surface(color = Accent100, shape = RoundedCornerShape(9.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(birthday.name, color = Accent900, fontWeight = FontWeight.SemiBold)
Text(birthday.dayMonth, color = Accent500, style = MaterialTheme.typography.labelSmall)
}
Text(relativeBirthdayLabel(birthday.inDays), color = Accent500)
}
}
}
private fun relativeBirthdayLabel(inDays: Int): String = when (inDays) {
0 -> "Heute"
1 -> "Morgen"
else -> "in $inDays Tagen"
}

View File

@@ -0,0 +1,59 @@
package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.BirthdayDto
import de.harheimertc.repositories.MemberAreaRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MemberAreaUiState(
val birthdays: List<BirthdayDto> = emptyList(),
val loadingBirthdays: Boolean = true,
val birthdayError: String? = null,
)
@HiltViewModel
class MemberAreaViewModel @Inject constructor(
private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() {
private val _state = MutableStateFlow(MemberAreaUiState())
val state: StateFlow<MemberAreaUiState> = _state
init {
loadBirthdays()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
loadBirthdays()
}
wasOnline = online
}
}
}
fun loadBirthdays() {
viewModelScope.launch {
_state.value = _state.value.copy(loadingBirthdays = true, birthdayError = null)
repository.birthdays()
.onSuccess { response ->
_state.value = _state.value.copy(
birthdays = response.birthdays,
loadingBirthdays = false,
)
}
.onFailure {
_state.value = _state.value.copy(
loadingBirthdays = false,
birthdayError = it.message ?: "Geburtstage konnten nicht geladen werden.",
)
}
}
}
}

View File

@@ -0,0 +1,218 @@
package de.harheimertc.ui.screens.membership
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun MembershipScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: MembershipViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val form = state.form
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Mitgliedschaft", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 14.dp))
Text(
"Werden Sie Teil unserer Tischtennis-Familie.",
color = Accent500,
modifier = Modifier.padding(top = 7.dp, bottom = 10.dp),
)
}
item {
InfoCard(
title = "Vereinssatzung",
text = "Unsere aktuelle Vereinssatzung und der Mitgliedsantrag stehen auf der Website als PDF bereit.",
)
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
FormHeading("Persönliche Daten")
TextInput("Vorname *", form.vorname, error = state.fieldErrors["vorname"]) { viewModel.update(form.copy(vorname = it)) }
TextInput("Nachname *", form.nachname, error = state.fieldErrors["nachname"]) { viewModel.update(form.copy(nachname = it)) }
TextInput("Straße und Hausnummer *", form.strasse, error = state.fieldErrors["strasse"]) { viewModel.update(form.copy(strasse = it)) }
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number, state.fieldErrors["plz"]) { viewModel.update(form.copy(plz = it)) }
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f), error = state.fieldErrors["ort"]) { viewModel.update(form.copy(ort = it)) }
}
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum, error = state.fieldErrors["geburtsdatum"]) { viewModel.update(form.copy(geburtsdatum = it)) }
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email, error = state.fieldErrors["email"]) { viewModel.update(form.copy(email = it)) }
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
FormHeading("Mitgliedschaftsart")
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
FeeInfo()
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift, state.fieldErrors["lastschrift"]) {
viewModel.update(form.copy(lastschrift = it))
}
FormHeading("Bankdaten für SEPA-Lastschrift")
TextInput("Kontoinhaber *", form.kontoinhaber, error = state.fieldErrors["kontoinhaber"]) { viewModel.update(form.copy(kontoinhaber = it)) }
TextInput("IBAN *", form.iban, error = state.fieldErrors["iban"]) { viewModel.update(form.copy(iban = it)) }
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
FormHeading("Datenschutz und Vereinssatzung")
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz, state.fieldErrors["datenschutz"]) {
viewModel.update(form.copy(datenschutz = it))
}
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung, state.fieldErrors["satzung"]) {
viewModel.update(form.copy(satzung = it))
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.message?.let { Text(it, color = Color(0xFF166534), fontWeight = FontWeight.SemiBold) }
Button(onClick = viewModel::submit, enabled = !state.sending, modifier = Modifier.fillMaxWidth()) {
if (state.sending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
Spacer(Modifier.width(8.dp))
}
Text(if (state.sending) "Formular wird erstellt..." else "Beitrittsformular erstellen")
}
state.pdfUri?.let { uri ->
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.parse(uri), "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
},
)
},
modifier = Modifier.fillMaxWidth(),
) { Text("Erstelltes PDF öffnen") }
}
}
}
}
item {
Surface(color = Primary600, shape = RoundedCornerShape(14.dp)) {
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Noch Fragen zur Mitgliedschaft?", color = Color.White, style = MaterialTheme.typography.titleLarge)
Text("Kontaktieren Sie uns - wir beraten Sie gerne persönlich.", color = Primary100, modifier = Modifier.padding(vertical = 12.dp))
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) {
Text("Jetzt Kontakt aufnehmen")
}
}
}
}
}
}
@Composable
private fun FormHeading(text: String) {
Text(text, fontWeight = FontWeight.SemiBold, color = Accent900, modifier = Modifier.padding(top = 12.dp))
}
@Composable
private fun TextInput(
label: String,
value: String,
modifier: Modifier = Modifier.fillMaxWidth(),
keyboard: KeyboardType = KeyboardType.Text,
error: String? = null,
onChange: (String) -> Unit,
) {
ValidatedTextField(
value = value,
onValueChange = onChange,
label = label,
error = error,
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
modifier = modifier,
singleLine = true,
)
}
@Composable
private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onClick)
Text(label, color = Accent700)
}
}
@Composable
private fun AgreementRow(label: String, selected: Boolean, error: String? = null, onChange: (Boolean) -> Unit) {
Column {
Row(verticalAlignment = Alignment.Top) {
Checkbox(checked = selected, onCheckedChange = onChange)
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp)) }
}
}
@Composable
private fun FeeInfo() {
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Jährlicher Mitgliedsbeitrag", fontWeight = FontWeight.SemiBold, color = Accent900)
Text("120 EUR Erwachsene | 72 EUR Jugendliche | 30 EUR passive Mitglieder", color = Accent700)
}
}
}
@Composable
private fun InfoCard(title: String, text: String) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(text, color = Accent500, modifier = Modifier.padding(top = 7.dp))
}
}
}

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