Compare commits
59 Commits
803481ca8e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
281c25b05d | ||
|
|
1beb5c2eee | ||
|
|
461f2de8dc | ||
|
|
75a7c409cd | ||
|
|
751c302036 | ||
|
|
f0e1ad39b0 | ||
|
|
1dc84023d0 | ||
|
|
30465c7833 | ||
|
|
d260f00756 | ||
|
|
f0ac4b7e56 | ||
|
|
bd45beadd5 | ||
|
|
f060c9771f | ||
|
|
0ab71166aa | ||
|
|
d9c5d242ec | ||
|
|
cb1e7a2fed | ||
|
|
3586704cce | ||
|
|
7f3b58c5bc | ||
|
|
f29e8a4dac | ||
|
|
b69130c2b2 | ||
|
|
77aabef4a9 | ||
|
|
4b699de853 | ||
|
|
e3cb7282bc | ||
|
|
e537839e28 | ||
|
|
44d441811c | ||
|
|
da1efa5a74 | ||
| c7a306e8fa | |||
|
|
14cd5f04d5 | ||
|
|
7e533fae49 | ||
|
|
8393f154e5 | ||
|
|
c956869e8a | ||
|
|
4eabb3b766 | ||
|
|
146dedd9b4 | ||
|
|
6076194497 | ||
|
|
9cde1ab78b | ||
|
|
f5facaa811 | ||
|
|
78015298ec | ||
|
|
b4e1c50ea3 | ||
|
|
5da11d2e4d | ||
|
|
e8a50e55ca | ||
|
|
530e544542 | ||
|
|
300dce9835 | ||
|
|
a98def915e | ||
|
|
7aa7970f2e | ||
|
|
e517720b03 | ||
|
|
402913d877 | ||
|
|
2014abe660 | ||
|
|
80834d8652 | ||
| 45de2a576c | |||
|
|
7bc98c03e4 | ||
|
|
bf1caefde4 | ||
|
|
6983186caf | ||
| e1ad5f7205 | |||
|
|
7c93966878 | ||
| 14341b7a63 | |||
|
|
31d20f1bff | ||
|
|
6507afea5f | ||
|
|
387ce6e08e | ||
|
|
f822fc8a8e | ||
|
|
67c746f18b |
14
.gitea/workflows/code-analysis.yml
Normal file → Executable file
14
.gitea/workflows/code-analysis.yml
Normal file → Executable file
@@ -6,10 +6,11 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
clean: true
|
clean: true
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -28,7 +29,7 @@ jobs:
|
|||||||
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -115,12 +116,11 @@ jobs:
|
|||||||
chmod +x osv-scanner
|
chmod +x osv-scanner
|
||||||
./osv-scanner --version
|
./osv-scanner --version
|
||||||
test -f ./package-lock.json
|
test -f ./package-lock.json
|
||||||
./osv-scanner --lockfile ./package-lock.json
|
./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
|
||||||
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
needs: analyze
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-p "${{ vars.PROD_PORT }}" \
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
"bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||||
|
|
||||||
deploy-test:
|
deploy-test:
|
||||||
needs: analyze
|
needs: analyze
|
||||||
@@ -177,4 +177,4 @@ jobs:
|
|||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-p "${{ vars.PROD_PORT }}" \
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
"bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||||
|
|||||||
0
.gitea/workflows/version-gate.yml
Normal file → Executable file
0
.gitea/workflows/version-gate.yml
Normal file → Executable file
6
.github/workflows/android-ci.yml
vendored
Normal file → Executable file
6
.github/workflows/android-ci.yml
vendored
Normal file → Executable file
@@ -10,14 +10,14 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
|
|||||||
1
.gitignore
vendored
Normal file → Executable file
1
.gitignore
vendored
Normal file → Executable file
@@ -93,6 +93,7 @@ dist
|
|||||||
/android-app/.kotlin/
|
/android-app/.kotlin/
|
||||||
/android-app/**/build/
|
/android-app/**/build/
|
||||||
/android-app/local.properties
|
/android-app/local.properties
|
||||||
|
/android-app/gradle-local.properties
|
||||||
|
|
||||||
# Build output (but keep production data!)
|
# Build output (but keep production data!)
|
||||||
.output
|
.output
|
||||||
|
|||||||
7
.gitleaks.toml
Executable file
7
.gitleaks.toml
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
[[allowlists]]
|
||||||
|
description = "generated/imported non-secret data"
|
||||||
|
paths = [
|
||||||
|
'''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''',
|
||||||
|
'''android-app/app/build/.*''',
|
||||||
|
'''android-app/\.idea/planningMode\.xml$''',
|
||||||
|
]
|
||||||
3
.gitleaksignore
Executable file
3
.gitleaksignore
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
google-services.json:gcp-api-key:18
|
||||||
|
google-services.json:gcp-api-key:37
|
||||||
|
google-services.json:gcp-api-key:56
|
||||||
4
.osv-scanner.toml
Normal file
4
.osv-scanner.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[[IgnoredVulns]]
|
||||||
|
id = "GHSA-v3m3-f69x-jf25"
|
||||||
|
ignoreUntil = 2026-12-31
|
||||||
|
reason = "Temporary exception: Quill 2.0.3 is required by the current RichTextEditor implementation, and OSV currently reports no fixed version. Track upstream fix and remove this ignore once a patched release is available."
|
||||||
0
.semgrepignore
Normal file → Executable file
0
.semgrepignore
Normal file → Executable file
0
ANDROID_ARCHITECTURE.md
Normal file → Executable file
0
ANDROID_ARCHITECTURE.md
Normal file → Executable file
0
ANDROID_KOTLIN_PLAN.md
Normal file → Executable file
0
ANDROID_KOTLIN_PLAN.md
Normal file → Executable file
0
ANDROID_PORT_TODO.md
Normal file → Executable file
0
ANDROID_PORT_TODO.md
Normal file → Executable file
0
ANDROID_REPO_ENDPOINTS.md
Normal file → Executable file
0
ANDROID_REPO_ENDPOINTS.md
Normal file → Executable file
0
AUTH_README.md
Normal file → Executable file
0
AUTH_README.md
Normal file → Executable file
0
DATENSCHUTZ.md
Normal file → Executable file
0
DATENSCHUTZ.md
Normal file → Executable file
0
DATENSCHUTZ_UEBERSICHT.md
Normal file → Executable file
0
DATENSCHUTZ_UEBERSICHT.md
Normal file → Executable file
0
DEPLOYMENT.md
Normal file → Executable file
0
DEPLOYMENT.md
Normal file → Executable file
36
android-app/PLAYSTORE_ASSETS.md
Normal file → Executable file
36
android-app/PLAYSTORE_ASSETS.md
Normal file → Executable file
@@ -36,13 +36,12 @@ Ausgabe in:
|
|||||||
|
|
||||||
## 3) Screenshots (anonymisiert)
|
## 3) Screenshots (anonymisiert)
|
||||||
|
|
||||||
### Grobe Anforderungen (Telefon)
|
### Zielgroessen fuer Store-Upload
|
||||||
- Mindestens 2 Screenshots
|
- Telefon (Portrait): 1080 x 1920
|
||||||
- PNG oder JPEG
|
- Medium 7" Tablet (Portrait): 1200 x 1920
|
||||||
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
- 10" Tablet (Portrait): 1600 x 2560
|
||||||
|
|
||||||
Empfehlung fuer Android-Phone:
|
Alle Dateien als PNG oder JPEG.
|
||||||
- 1080 x 1920 (Portrait)
|
|
||||||
|
|
||||||
### Anonymisierung
|
### Anonymisierung
|
||||||
|
|
||||||
@@ -61,10 +60,33 @@ Beispiel:
|
|||||||
'68,118,520,72;70,706,560,98'
|
'68,118,520,72;70,706,560,98'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Zielprofile erzeugen (Telefon, 7", 10")
|
||||||
|
|
||||||
|
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/playstore-screenshot-sizes.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional mit eigenen Ordnern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/playstore-screenshot-sizes.sh \
|
||||||
|
--input-dir android-app/playstore-assets/anon \
|
||||||
|
--output-dir android-app/playstore-assets/final
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- android-app/playstore-assets/final/phone
|
||||||
|
- android-app/playstore-assets/final/tablet-7
|
||||||
|
- android-app/playstore-assets/final/tablet-10
|
||||||
|
|
||||||
## 4) Upload in Play Console
|
## 4) Upload in Play Console
|
||||||
|
|
||||||
- Datenschutzerklaerung: URL eintragen
|
- Datenschutzerklaerung: URL eintragen
|
||||||
- Konto-Loeschung: URL eintragen
|
- Konto-Loeschung: URL eintragen
|
||||||
- App-Icon: playstore-icon-512.png
|
- App-Icon: playstore-icon-512.png
|
||||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||||
- Screenshots: anonymisierte PNG/JPEG hochladen
|
- Screenshots Telefon: Dateien aus `.../final/phone`
|
||||||
|
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
|
||||||
|
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`
|
||||||
|
|||||||
70
android-app/app/build.gradle.kts
Normal file → Executable file
70
android-app/app/build.gradle.kts
Normal file → Executable file
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("com.google.devtools.ksp")
|
id("com.google.devtools.ksp")
|
||||||
@@ -5,12 +7,17 @@ plugins {
|
|||||||
id("com.google.dagger.hilt.android")
|
id("com.google.dagger.hilt.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file("google-services.json").exists()) {
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
|
}
|
||||||
|
|
||||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||||
.orElse("https://harheimertc.tsschulz.de/")
|
.orElse("https://harheimertc.tsschulz.de/")
|
||||||
.get()
|
.get()
|
||||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||||
.orElse("https://harheimertc.de/")
|
.orElse("https://harheimertc.de/")
|
||||||
.get()
|
.get()
|
||||||
|
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
|
||||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||||
.orElse("")
|
.orElse("")
|
||||||
.get()
|
.get()
|
||||||
@@ -25,15 +32,50 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
|||||||
.orElse("true")
|
.orElse("true")
|
||||||
.get()
|
.get()
|
||||||
.toBoolean()
|
.toBoolean()
|
||||||
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
|
||||||
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
val localSigningProperties = Properties().apply {
|
||||||
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
val localSigningFile = rootProject.file("gradle-local.properties")
|
||||||
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
if (localSigningFile.exists()) {
|
||||||
|
localSigningFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signingProperty(name: String): String? =
|
||||||
|
providers.gradleProperty(name).orNull
|
||||||
|
?: providers.environmentVariable(name).orNull
|
||||||
|
?: localSigningProperties.getProperty(name)
|
||||||
|
|
||||||
|
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
|
||||||
|
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
|
||||||
|
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
|
||||||
|
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
|
||||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||||
!releaseStorePassword.isNullOrBlank() &&
|
!releaseStorePassword.isNullOrBlank() &&
|
||||||
!releaseKeyAlias.isNullOrBlank() &&
|
!releaseKeyAlias.isNullOrBlank() &&
|
||||||
!releaseKeyPassword.isNullOrBlank()
|
!releaseKeyPassword.isNullOrBlank()
|
||||||
|
|
||||||
|
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
||||||
|
doFirst {
|
||||||
|
if (!hasReleaseSigning) {
|
||||||
|
throw GradleException(
|
||||||
|
"Production release signing is not configured. " +
|
||||||
|
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
|
||||||
|
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||||
|
doFirst {
|
||||||
|
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
|
||||||
|
throw GradleException(
|
||||||
|
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.harheimertc"
|
namespace = "de.harheimertc"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -46,6 +88,13 @@ android {
|
|||||||
versionName = androidVersionName
|
versionName = androidVersionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable += setOf(
|
||||||
|
"AutoboxingStateCreation",
|
||||||
|
"MutableCollectionMutableState",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
if (hasReleaseSigning) {
|
if (hasReleaseSigning) {
|
||||||
@@ -135,6 +184,8 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
|
|||||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||||
group = "distribution"
|
group = "distribution"
|
||||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||||
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
dependsOn(":app:bundleProductionRelease")
|
dependsOn(":app:bundleProductionRelease")
|
||||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||||
|
|
||||||
@@ -161,6 +212,13 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.matching {
|
||||||
|
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||||
|
}.configureEach {
|
||||||
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
@@ -206,6 +264,9 @@ dependencies {
|
|||||||
// Crash reporting
|
// Crash reporting
|
||||||
implementation("io.sentry:sentry-android:8.42.0")
|
implementation("io.sentry:sentry-android:8.42.0")
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
implementation("com.google.firebase:firebase-messaging:25.0.2")
|
||||||
|
|
||||||
// Room
|
// Room
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
@@ -215,6 +276,7 @@ dependencies {
|
|||||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||||
|
|
||||||
// Testing (skeleton)
|
// Testing (skeleton)
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|||||||
1
android-app/app/google-services.json
Symbolic link
1
android-app/app/google-services.json
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../google-services.json
|
||||||
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file → Executable file
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file → Executable file
Binary file not shown.
BIN
android-app/app/local/release/app-local-release.aab
Normal file → Executable file
BIN
android-app/app/local/release/app-local-release.aab
Normal file → Executable file
Binary file not shown.
BIN
android-app/app/production/release/app-production-release.aab
Normal file → Executable file
BIN
android-app/app/production/release/app-production-release.aab
Normal file → Executable file
Binary file not shown.
37
android-app/app/proguard-rules.pro
vendored
Normal file → Executable file
37
android-app/app/proguard-rules.pro
vendored
Normal file → Executable file
@@ -1,2 +1,37 @@
|
|||||||
# Project-specific R8/ProGuard rules for release builds.
|
# Project-specific R8/ProGuard rules for release builds.
|
||||||
# Keep this file intentionally minimal and add rules only when needed.
|
|
||||||
|
# Keep reflection/generic metadata used by Retrofit + Moshi.
|
||||||
|
-keepattributes Signature,InnerClasses,EnclosingMethod
|
||||||
|
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# Keep Retrofit service interfaces and HTTP method annotations.
|
||||||
|
-keep,allowobfuscation interface * {
|
||||||
|
@retrofit2.http.* <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
|
||||||
|
-if interface * { @retrofit2.http.* <methods>; }
|
||||||
|
-keep,allowobfuscation interface <1>
|
||||||
|
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||||
|
|
||||||
|
# Avoid Retrofit generic signature loss on release builds for our API interface.
|
||||||
|
-keep interface de.harheimertc.data.ApiService { *; }
|
||||||
|
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
|
||||||
|
|
||||||
|
# Keep app DTO/request/response models used via Moshi reflection.
|
||||||
|
-keep class de.harheimertc.data.*Dto { *; }
|
||||||
|
-keep class de.harheimertc.data.*Request { *; }
|
||||||
|
-keep class de.harheimertc.data.*Response { *; }
|
||||||
|
|
||||||
|
# Keep fields annotated with @Json names.
|
||||||
|
-keepclassmembers class * {
|
||||||
|
@com.squareup.moshi.Json <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep WorkManager + Room generated classes used reflectively at startup.
|
||||||
|
-keep class * extends androidx.work.ListenableWorker {
|
||||||
|
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||||
|
}
|
||||||
|
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
|||||||
0
android-app/app/src/androidTest/AndroidManifest.xml
Normal file → Executable file
0
android-app/app/src/androidTest/AndroidManifest.xml
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestBindingsModule.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestBindingsModule.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsActivateResendTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsActivateResendTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsRolesDialogTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsRolesDialogTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/gallery/GalleryScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/gallery/GalleryScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/home/HomeScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/home/HomeScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/login/LoginScreenTest.kt
Normal file → Executable file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/login/LoginScreenTest.kt
Normal file → Executable file
0
android-app/app/src/debug/AndroidManifest.xml
Normal file → Executable file
0
android-app/app/src/debug/AndroidManifest.xml
Normal file → Executable file
11
android-app/app/src/main/AndroidManifest.xml
Normal file → Executable file
11
android-app/app/src/main/AndroidManifest.xml
Normal file → Executable file
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HarheimerApplication"
|
android:name=".HarheimerApplication"
|
||||||
@@ -10,12 +11,20 @@
|
|||||||
android:theme="@style/Theme.HarheimerTC"
|
android:theme="@style/Theme.HarheimerTC"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
<activity android:name="de.harheimertc.MainActivity"
|
<activity android:name="de.harheimertc.MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".notifications.HarheimerMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.files"
|
android:authorities="${applicationId}.files"
|
||||||
|
|||||||
2
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Normal file → Executable file
2
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Normal file → Executable file
@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
|
|||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
import io.sentry.Sentry
|
import io.sentry.Sentry
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
HarheimerNotifications.createChannels(this)
|
||||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||||
Sentry.init { options ->
|
Sentry.init { options ->
|
||||||
options.dsn = BuildConfig.SENTRY_DSN
|
options.dsn = BuildConfig.SENTRY_DSN
|
||||||
|
|||||||
56
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file → Executable file
56
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file → Executable file
@@ -1,13 +1,20 @@
|
|||||||
package de.harheimertc
|
package de.harheimertc
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import de.harheimertc.ui.navigation.NavGraph
|
import de.harheimertc.ui.navigation.NavGraph
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
import de.harheimertc.ui.theme.HarheimerTheme
|
import de.harheimertc.ui.theme.HarheimerTheme
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
@@ -17,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val notificationRoute = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
notificationRoute.value = extractNotificationRoute(intent)
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(
|
||||||
|
notificationRoute = notificationRoute.value,
|
||||||
|
onNotificationRouteConsumed = { notificationRoute.value = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
notificationRoute.value = extractNotificationRoute(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||||
|
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
|
||||||
|
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
|
||||||
|
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(
|
||||||
|
notificationRoute: String? = null,
|
||||||
|
onNotificationRouteConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
HarheimerTheme {
|
HarheimerTheme {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
val activity = ctx as? ComponentActivity
|
val activity = ctx as? ComponentActivity
|
||||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||||
|
LaunchedEffect(notificationRoute) {
|
||||||
|
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
|
||||||
|
navController.navigate(route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
popUpTo(Destinations.Home.route)
|
||||||
|
}
|
||||||
|
onNotificationRouteConsumed()
|
||||||
|
}
|
||||||
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
Normal file → Executable file
108
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file → Executable file
108
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file → Executable file
@@ -21,6 +21,7 @@ import okhttp3.RequestBody
|
|||||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
|
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
data class TerminDto(
|
data class TerminDto(
|
||||||
val datum: String = "",
|
val datum: String = "",
|
||||||
val uhrzeit: String? = null,
|
val uhrzeit: String? = null,
|
||||||
@@ -37,6 +38,12 @@ data class SpielplanResponse(
|
|||||||
val seasons: List<SeasonDto> = emptyList(),
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
)
|
)
|
||||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||||
|
data class MannschaftenSeasonsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val seasons: List<String> = emptyList(),
|
||||||
|
val currentSeason: String = "",
|
||||||
|
val defaultSeason: String = "",
|
||||||
|
)
|
||||||
data class SpielDto(
|
data class SpielDto(
|
||||||
@param:Json(name = "Termin") val termin: String = "",
|
@param:Json(name = "Termin") val termin: String = "",
|
||||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||||
@@ -81,7 +88,7 @@ data class LeagueTableRowDto(
|
|||||||
)
|
)
|
||||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||||
data class NewsDto(
|
data class NewsDto(
|
||||||
val id: Int? = null,
|
val id: String? = null,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val content: String = "",
|
val content: String = "",
|
||||||
val created: String? = null,
|
val created: String? = null,
|
||||||
@@ -96,7 +103,7 @@ data class NewsResponse(
|
|||||||
val news: List<NewsDto> = emptyList(),
|
val news: List<NewsDto> = emptyList(),
|
||||||
)
|
)
|
||||||
data class NewsSaveRequest(
|
data class NewsSaveRequest(
|
||||||
val id: Int? = null,
|
val id: String? = null,
|
||||||
val title: String,
|
val title: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val isPublic: Boolean = false,
|
val isPublic: Boolean = false,
|
||||||
@@ -225,7 +232,7 @@ data class ProfileVisibilityDto(
|
|||||||
val showEmail: Boolean = true,
|
val showEmail: Boolean = true,
|
||||||
val showPhone: Boolean = true,
|
val showPhone: Boolean = true,
|
||||||
val showAddress: Boolean = false,
|
val showAddress: Boolean = false,
|
||||||
val showBirthday: Boolean = true,
|
val showBirthday: Boolean = false,
|
||||||
)
|
)
|
||||||
data class ProfileUserDto(
|
data class ProfileUserDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
@@ -251,6 +258,30 @@ data class ProfileUpdateRequest(
|
|||||||
val currentPassword: String? = null,
|
val currentPassword: String? = null,
|
||||||
val newPassword: String? = null,
|
val newPassword: String? = null,
|
||||||
)
|
)
|
||||||
|
data class NotificationSettingsDto(
|
||||||
|
val newNews: Boolean = false,
|
||||||
|
val newEvents: Boolean = false,
|
||||||
|
val eventsToday: Boolean = false,
|
||||||
|
val eventsTomorrow: Boolean = false,
|
||||||
|
val ownTeamMatches: Boolean = false,
|
||||||
|
val allTeamMatches: Boolean = false,
|
||||||
|
val birthdays: Boolean = false,
|
||||||
|
val newContactRequest: Boolean = false,
|
||||||
|
val newUserRegistration: Boolean = false,
|
||||||
|
val selectedTeamSlugs: List<String> = emptyList(),
|
||||||
|
val selectedTeamSeason: String? = null,
|
||||||
|
val notificationTime: String = "09:00",
|
||||||
|
)
|
||||||
|
data class NotificationSettingsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val settings: NotificationSettingsDto = NotificationSettingsDto(),
|
||||||
|
)
|
||||||
|
data class PushTokenRequest(
|
||||||
|
val token: String,
|
||||||
|
val platform: String = "android",
|
||||||
|
val appVersion: String? = null,
|
||||||
|
)
|
||||||
data class BirthdayDto(
|
data class BirthdayDto(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val dayMonth: String = "",
|
val dayMonth: String = "",
|
||||||
@@ -260,6 +291,28 @@ data class BirthdaysResponse(
|
|||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
val birthdays: List<BirthdayDto> = emptyList(),
|
val birthdays: List<BirthdayDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
data class QttrSourceDto(
|
||||||
|
val url: String = "",
|
||||||
|
)
|
||||||
|
data class QttrRowDto(
|
||||||
|
val rank: Int? = null,
|
||||||
|
val playerNumber: Int? = null,
|
||||||
|
val gender: String? = null,
|
||||||
|
val playerName: String = "",
|
||||||
|
val clubName: String = "",
|
||||||
|
val currentQttr: Int? = null,
|
||||||
|
val previousQttr: Int? = null,
|
||||||
|
val birthdate: String? = null,
|
||||||
|
)
|
||||||
|
data class QttrValuesResponse(
|
||||||
|
val format: String = "",
|
||||||
|
val importedAt: String = "",
|
||||||
|
val source: QttrSourceDto = QttrSourceDto(),
|
||||||
|
val title: String? = null,
|
||||||
|
val headerCount: Int = 0,
|
||||||
|
val rowCount: Int = 0,
|
||||||
|
val rows: List<QttrRowDto> = emptyList(),
|
||||||
|
)
|
||||||
data class MemberDto(
|
data class MemberDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@@ -276,6 +329,7 @@ data class MemberDto(
|
|||||||
val editable: Boolean = false,
|
val editable: Boolean = false,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
|
val showBirthday: Boolean = false,
|
||||||
val loginRoles: List<String> = emptyList(),
|
val loginRoles: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
data class MembersResponse(
|
data class MembersResponse(
|
||||||
@@ -387,6 +441,15 @@ data class HomepageSectionConfigDto(
|
|||||||
data class HomepageDto(
|
data class HomepageDto(
|
||||||
val sections: List<HomepageSectionDto> = emptyList(),
|
val sections: List<HomepageSectionDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
data class HeroImageVariantDto(
|
||||||
|
val key: String = "",
|
||||||
|
val mobileWebp: String = "",
|
||||||
|
val desktopWebp: String = "",
|
||||||
|
val fallback: String = "",
|
||||||
|
)
|
||||||
|
data class HeroImagesResponse(
|
||||||
|
val variants: List<HeroImageVariantDto> = emptyList(),
|
||||||
|
)
|
||||||
data class SeitenDto(
|
data class SeitenDto(
|
||||||
val ueberUns: String = "",
|
val ueberUns: String = "",
|
||||||
val geschichte: String = "",
|
val geschichte: String = "",
|
||||||
@@ -529,6 +592,21 @@ interface ApiService {
|
|||||||
@GET("/api/termine")
|
@GET("/api/termine")
|
||||||
suspend fun termine(): Response<TermineResponse>
|
suspend fun termine(): Response<TermineResponse>
|
||||||
|
|
||||||
|
@GET("/api/termine-manage")
|
||||||
|
suspend fun termineManage(): Response<TermineManageResponse>
|
||||||
|
|
||||||
|
@POST("/api/termine-manage")
|
||||||
|
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@DELETE("/api/termine-manage")
|
||||||
|
suspend fun deleteTermin(
|
||||||
|
@Query("datum") datum: String,
|
||||||
|
@Query("uhrzeit") uhrzeit: String = "",
|
||||||
|
@Query("titel") titel: String,
|
||||||
|
@Query("beschreibung") beschreibung: String = "",
|
||||||
|
@Query("kategorie") kategorie: String = "Sonstiges",
|
||||||
|
): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/spielplan")
|
@GET("/api/spielplan")
|
||||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||||
|
|
||||||
@@ -548,14 +626,20 @@ interface ApiService {
|
|||||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@DELETE("/api/news")
|
@DELETE("/api/news")
|
||||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/mannschaften")
|
@GET("/api/mannschaften")
|
||||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("/api/mannschaften/seasons")
|
||||||
|
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||||
|
|
||||||
@GET("/api/config")
|
@GET("/api/config")
|
||||||
suspend fun config(): Response<ConfigResponse>
|
suspend fun config(): Response<ConfigResponse>
|
||||||
|
|
||||||
|
@GET("/api/hero-images")
|
||||||
|
suspend fun heroImages(): Response<HeroImagesResponse>
|
||||||
|
|
||||||
@PUT("/api/config")
|
@PUT("/api/config")
|
||||||
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||||
|
|
||||||
@@ -568,6 +652,9 @@ interface ApiService {
|
|||||||
@POST("/api/cms/save-csv")
|
@POST("/api/cms/save-csv")
|
||||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||||
|
|
||||||
|
@POST("/api/cms/import-spielplan")
|
||||||
|
suspend fun importSpielplan(): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@POST("/api/membership/generate-pdf")
|
@POST("/api/membership/generate-pdf")
|
||||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||||
|
|
||||||
@@ -617,9 +704,21 @@ interface ApiService {
|
|||||||
@retrofit2.http.PUT("/api/profile")
|
@retrofit2.http.PUT("/api/profile")
|
||||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||||
|
|
||||||
|
@GET("/api/profile/notifications")
|
||||||
|
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
|
||||||
|
|
||||||
|
@retrofit2.http.PUT("/api/profile/notifications")
|
||||||
|
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
|
||||||
|
|
||||||
|
@POST("/api/profile/push-token")
|
||||||
|
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/birthdays")
|
@GET("/api/birthdays")
|
||||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||||
|
|
||||||
|
@GET("/api/mitgliederbereich/qttr")
|
||||||
|
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||||
|
|
||||||
@GET("/api/members")
|
@GET("/api/members")
|
||||||
suspend fun members(): Response<MembersResponse>
|
suspend fun members(): Response<MembersResponse>
|
||||||
|
|
||||||
@@ -634,6 +733,7 @@ interface ApiService {
|
|||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
|
val showBirthday: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Normal file → Executable file
49
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt
Executable file
49
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityMonitor @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val _online = MutableStateFlow(hasInternetAccess())
|
||||||
|
val online: StateFlow<Boolean> = _online.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { poll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun poll() {
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val current = hasInternetAccess()
|
||||||
|
if (_online.value != current) {
|
||||||
|
_online.value = current
|
||||||
|
}
|
||||||
|
delay(10_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasInternetAccess(): Boolean {
|
||||||
|
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return false
|
||||||
|
val network = manager.activeNetwork ?: return false
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
}
|
||||||
0
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Normal file → Executable file
34
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Normal file → Executable file
34
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Normal file → Executable file
@@ -1,11 +1,13 @@
|
|||||||
package de.harheimertc.data
|
package de.harheimertc.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -14,8 +16,10 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val moshi: Moshi,
|
private val moshi: Moshi,
|
||||||
) {
|
) {
|
||||||
|
private val tag = "SecureOfflineCache"
|
||||||
private companion object {
|
private companion object {
|
||||||
const val KEY_BIRTHDAYS = "birthdays"
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
|
const val KEY_QTTR_VALUES = "qttr_values"
|
||||||
const val KEY_MEMBERS = "members"
|
const val KEY_MEMBERS = "members"
|
||||||
const val KEY_MEMBER_NEWS = "member_news"
|
const val KEY_MEMBER_NEWS = "member_news"
|
||||||
const val KEY_CMS_CONFIG = "cms_config"
|
const val KEY_CMS_CONFIG = "cms_config"
|
||||||
@@ -28,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -38,11 +46,36 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_offline_cache")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_offline_cache",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
|
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
|
||||||
|
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||||
|
|
||||||
@@ -93,6 +126,7 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
KEY_NEWSLETTER_GROUPS,
|
KEY_NEWSLETTER_GROUPS,
|
||||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||||
KEY_MEMBER_NEWS,
|
KEY_MEMBER_NEWS,
|
||||||
|
KEY_QTTR_VALUES,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Normal file → Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
package de.harheimertc.notifications
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class HarheimerMessagingService : FirebaseMessagingService() {
|
||||||
|
@Inject
|
||||||
|
lateinit var pushTokenRepository: PushTokenRepository
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
serviceScope.launch {
|
||||||
|
pushTokenRepository.registerToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
val title = message.notification?.title
|
||||||
|
?: message.data["title"]
|
||||||
|
?: "Harheimer TC"
|
||||||
|
val body = message.notification?.body
|
||||||
|
?: message.data["body"]
|
||||||
|
?: message.data["message"]
|
||||||
|
?: return
|
||||||
|
val notificationId = message.data["notificationId"]?.toIntOrNull()
|
||||||
|
?: message.messageId?.hashCode()
|
||||||
|
?: System.currentTimeMillis().toInt()
|
||||||
|
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body, message.data)
|
||||||
|
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package de.harheimertc.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.harheimertc.MainActivity
|
||||||
|
import de.harheimertc.R
|
||||||
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
|
||||||
|
object HarheimerNotifications {
|
||||||
|
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
|
||||||
|
|
||||||
|
fun createChannels(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
DEFAULT_CHANNEL_ID,
|
||||||
|
"Harheimer TC",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = "Benachrichtigungen des Harheimer TC"
|
||||||
|
}
|
||||||
|
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNotificationPermission(context: Context): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
fun showBasicNotification(
|
||||||
|
context: Context,
|
||||||
|
notificationId: Int,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
data: Map<String, String> = emptyMap(),
|
||||||
|
): Boolean {
|
||||||
|
if (!hasNotificationPermission(context)) return false
|
||||||
|
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(message)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
|
true
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||||
|
val route = destinationRoute(payload)
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
|
||||||
|
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
|
||||||
|
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
notificationId,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||||
|
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||||
|
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||||
|
"team_matches" -> Destinations.Spielplan.route
|
||||||
|
"birthdays" -> Destinations.MemberArea.route
|
||||||
|
"contact_request" -> Destinations.CmsContactRequests.route
|
||||||
|
"user_registration" -> Destinations.CmsBenutzer.route
|
||||||
|
else -> Destinations.Home.route
|
||||||
|
}
|
||||||
|
}
|
||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Normal file → Executable file
29
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Normal file → Executable file
29
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Normal file → Executable file
@@ -1,10 +1,12 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.harheimertc.security.DeviceKeyManager
|
import de.harheimertc.security.DeviceKeyManager
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val deviceKeyManager: DeviceKeyManager,
|
private val deviceKeyManager: DeviceKeyManager,
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
private val tag = "AuthRepository"
|
||||||
private val tokenKey = "auth_token"
|
private val tokenKey = "auth_token"
|
||||||
private val refreshTokenKey = "auth_refresh_token"
|
private val refreshTokenKey = "auth_refresh_token"
|
||||||
private val sessionIdKey = "auth_session_id"
|
private val sessionIdKey = "auth_session_id"
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_auth")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_auth",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||||
|
|||||||
146
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Normal file → Executable file
146
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Normal file → Executable file
@@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
|||||||
import de.harheimertc.data.SaveCsvRequest
|
import de.harheimertc.data.SaveCsvRequest
|
||||||
import de.harheimertc.data.SaveCsvResponse
|
import de.harheimertc.data.SaveCsvResponse
|
||||||
import de.harheimertc.data.SecureOfflineCache
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
|
import de.harheimertc.data.SpielplanResponse
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CmsRepository @Inject constructor(
|
class CmsRepository @Inject constructor(
|
||||||
@@ -83,6 +85,93 @@ class CmsRepository @Inject constructor(
|
|||||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
|
||||||
|
val response = api.termineManage()
|
||||||
|
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.saveTermin(request)
|
||||||
|
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.deleteTermin(
|
||||||
|
datum = request.datum,
|
||||||
|
uhrzeit = request.uhrzeit.orEmpty(),
|
||||||
|
titel = request.titel,
|
||||||
|
beschreibung = request.beschreibung.orEmpty(),
|
||||||
|
kategorie = request.kategorie ?: "Sonstiges",
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
|
||||||
|
val response = api.mannschaften(season)
|
||||||
|
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||||
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
|
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
|
||||||
|
CmsMannschaftRow(
|
||||||
|
mannschaft = values[0],
|
||||||
|
liga = values[1],
|
||||||
|
staffelleiter = values[2],
|
||||||
|
telefon = values[3],
|
||||||
|
heimspieltag = values[4],
|
||||||
|
spielsystem = values[5],
|
||||||
|
mannschaftsfuehrer = values[6],
|
||||||
|
spieler = values[7],
|
||||||
|
informationenLink = values[8],
|
||||||
|
letzteAktualisierung = values[9],
|
||||||
|
spielplanMannschaftId = values.getOrElse(10) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
|
||||||
|
val response = api.saveCsv(
|
||||||
|
SaveCsvRequest(
|
||||||
|
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
|
||||||
|
content = rows.toMannschaftenCsv(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
|
||||||
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.importSpielplan()
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||||
|
val response = api.spielplan(season)
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
||||||
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
|
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
|
||||||
|
val response = api.saveCsv(
|
||||||
|
SaveCsvRequest(
|
||||||
|
filename = "spielplan.csv",
|
||||||
|
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun users(): Result<CmsUsersResponse> =
|
suspend fun users(): Result<CmsUsersResponse> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -267,7 +356,7 @@ class CmsRepository @Inject constructor(
|
|||||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
val response = api.deleteNews(id)
|
val response = api.deleteNews(id)
|
||||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||||
cache.clearCmsNewsCache()
|
cache.clearCmsNewsCache()
|
||||||
@@ -288,6 +377,61 @@ class CmsRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CmsMannschaftRow(
|
||||||
|
val mannschaft: String = "",
|
||||||
|
val liga: String = "",
|
||||||
|
val staffelleiter: String = "",
|
||||||
|
val telefon: String = "",
|
||||||
|
val heimspieltag: String = "",
|
||||||
|
val spielsystem: String = "",
|
||||||
|
val mannschaftsfuehrer: String = "",
|
||||||
|
val spieler: String = "",
|
||||||
|
val informationenLink: String = "",
|
||||||
|
val letzteAktualisierung: String = "",
|
||||||
|
val spielplanMannschaftId: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||||
|
val header = listOf(
|
||||||
|
"Mannschaft",
|
||||||
|
"Liga",
|
||||||
|
"Staffelleiter",
|
||||||
|
"Telefon",
|
||||||
|
"Heimspieltag",
|
||||||
|
"Spielsystem",
|
||||||
|
"Mannschaftsführer",
|
||||||
|
"Spieler",
|
||||||
|
"Weitere Informationen Link",
|
||||||
|
"Letzte Aktualisierung",
|
||||||
|
"Spielplan Mannschaft ID",
|
||||||
|
).toCsvRow()
|
||||||
|
val rows = map { row ->
|
||||||
|
listOf(
|
||||||
|
row.mannschaft,
|
||||||
|
row.liga,
|
||||||
|
row.staffelleiter,
|
||||||
|
row.telefon,
|
||||||
|
row.heimspieltag,
|
||||||
|
row.spielsystem,
|
||||||
|
row.mannschaftsfuehrer,
|
||||||
|
row.spieler,
|
||||||
|
row.informationenLink,
|
||||||
|
row.letzteAktualisierung,
|
||||||
|
row.spielplanMannschaftId,
|
||||||
|
).toCsvRow()
|
||||||
|
}
|
||||||
|
return listOf(header).plus(rows).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.toCsvRow(delimiter: String = ","): String =
|
||||||
|
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
|
||||||
|
|
||||||
|
private fun String.csvEscape(delimiter: String): String {
|
||||||
|
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||||
|
val escaped = replace("\"", "\"\"")
|
||||||
|
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<List<String>> =
|
private fun parseCsv(csv: String): List<List<String>> =
|
||||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||||
|
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt
Normal file → Executable file
28
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Normal file → Executable file
28
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Normal file → Executable file
@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||||
val response = api.galerieList(page = 1, perPage = 1)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.galerieList(page = 1, perPage = 1)
|
||||||
response.body()?.images.orEmpty().isNotEmpty()
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.images.orEmpty().isNotEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||||
return try {
|
return runCatching {
|
||||||
val resp = api.galerieList(page = page, perPage = perPage)
|
retryOnNetworkFailure {
|
||||||
if (resp.isSuccessful) {
|
val resp = api.galerieList(page = page, perPage = perPage)
|
||||||
val body = resp.body()
|
if (resp.isSuccessful) {
|
||||||
Result.success(
|
val body = resp.body()
|
||||||
GalleryPage(
|
GalleryPage(
|
||||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||||
),
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
error("HTTP ${resp.code()}")
|
||||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt
Normal file → Executable file
29
android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt
Normal file → Executable file
@@ -1,12 +1,14 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.harheimertc.data.HomepageSectionDto
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val moshi: Moshi,
|
private val moshi: Moshi,
|
||||||
) {
|
) {
|
||||||
|
private val tag = "HomeLayoutPreferences"
|
||||||
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||||
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||||
|
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_home_layout")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_home_layout",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSections(): List<HomepageSectionDto>? {
|
fun getSections(): List<HomepageSectionDto>? {
|
||||||
|
|||||||
233
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Normal file → Executable file
233
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Normal file → Executable file
@@ -1,12 +1,16 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
import de.harheimertc.data.HomepageSectionDto
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import de.harheimertc.data.HeroImageVariantDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.SeasonDto
|
import de.harheimertc.data.SeasonDto
|
||||||
import de.harheimertc.data.SpielDto
|
import de.harheimertc.data.SpielDto
|
||||||
import de.harheimertc.data.SpielplanResponse
|
import de.harheimertc.data.SpielplanResponse
|
||||||
import de.harheimertc.data.TerminDto
|
import de.harheimertc.data.TerminDto
|
||||||
|
import io.sentry.Sentry
|
||||||
|
import kotlin.random.Random
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -17,20 +21,162 @@ data class HomeData(
|
|||||||
val selectedSpielplanSeason: String?,
|
val selectedSpielplanSeason: String?,
|
||||||
val news: List<NewsDto>,
|
val news: List<NewsDto>,
|
||||||
val homepageSections: List<HomepageSectionDto>,
|
val homepageSections: List<HomepageSectionDto>,
|
||||||
|
val heroImageUrl: String? = null,
|
||||||
|
val diagnostics: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||||
val termine = api.termine().body()?.termine.orEmpty()
|
val diagnostics = mutableListOf<String>()
|
||||||
val spielplanResponse = api.spielplan().body()
|
|
||||||
val spiele = spielplanResponse?.data.orEmpty()
|
val termine = runCatching {
|
||||||
val news = api.publicNews().body()?.news.orEmpty()
|
retryOnNetworkFailure {
|
||||||
val homepageSections = runCatching {
|
val response = api.termine()
|
||||||
val configResponse = api.config()
|
if (!response.isSuccessful) {
|
||||||
if (!configResponse.isSuccessful) return@runCatching emptyList()
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
configResponse.body()?.homepage?.sections.orEmpty()
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/termine",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.termine", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/termine",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val spielplanResponse = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.spielplan()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/spielplan",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/spielplan",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
val spiele = spielplanResponse?.data.orEmpty()
|
||||||
|
|
||||||
|
val news = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.publicNews()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/news-public",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.news.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.news", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/news-public",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val homepageSections = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.config()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/config",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.homepage?.sections.orEmpty()
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.config", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/config",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val heroImageUrl = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.heroImages()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/hero-images",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = response.code(),
|
||||||
|
responseBody = errorBody,
|
||||||
|
throwable = null,
|
||||||
|
)
|
||||||
|
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
val variants = response.body()?.variants.orEmpty()
|
||||||
|
pickRandomHeroImage(variants)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
captureLoadIssue("fetchHomeData.heroImages", error)
|
||||||
|
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
||||||
|
diagnostics += buildDiagnostic(
|
||||||
|
endpoint = "GET /api/hero-images",
|
||||||
|
requestPayload = "none",
|
||||||
|
httpCode = null,
|
||||||
|
responseBody = null,
|
||||||
|
throwable = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
HomeData(
|
HomeData(
|
||||||
termine = termine,
|
termine = termine,
|
||||||
spiele = spiele,
|
spiele = spiele,
|
||||||
@@ -38,12 +184,77 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
selectedSpielplanSeason = spielplanResponse?.season,
|
selectedSpielplanSeason = spielplanResponse?.season,
|
||||||
news = news,
|
news = news,
|
||||||
homepageSections = homepageSections,
|
homepageSections = homepageSections,
|
||||||
|
heroImageUrl = heroImageUrl,
|
||||||
|
diagnostics = diagnostics,
|
||||||
)
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", "fetchHomeData")
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||||
val response = api.spielplan(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplan(season)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", "fetchSpielplanForSeason")
|
||||||
|
scope.setExtra("season", season)
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLoadIssue(operation: String, error: Throwable) {
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
scope.setTag("operation", operation)
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDiagnostic(
|
||||||
|
endpoint: String,
|
||||||
|
requestPayload: String,
|
||||||
|
httpCode: Int?,
|
||||||
|
responseBody: String?,
|
||||||
|
throwable: Throwable?,
|
||||||
|
): String {
|
||||||
|
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
|
||||||
|
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
|
||||||
|
return buildString {
|
||||||
|
append("Endpoint: ").append(endpoint).append('\n')
|
||||||
|
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
|
||||||
|
append("Request: ").append(requestPayload).append('\n')
|
||||||
|
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
|
||||||
|
append("Response: ").append(responsePreview).append('\n')
|
||||||
|
append("Throwable: ").append(throwableInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
|
||||||
|
if (variants.isEmpty()) return null
|
||||||
|
val valid = variants.filter { it.fallback.isNotBlank() }
|
||||||
|
if (valid.isEmpty()) return null
|
||||||
|
val selected = valid[Random.nextInt(valid.size)]
|
||||||
|
return toAbsoluteUrl(selected.fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toAbsoluteUrl(pathOrUrl: String): String {
|
||||||
|
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||||
|
return pathOrUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||||
|
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
|
||||||
|
return "$base$path"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Normal file → Executable file
78
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Normal file → Executable file
@@ -1,5 +1,6 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
import de.harheimertc.data.LoginRequest
|
import de.harheimertc.data.LoginRequest
|
||||||
import de.harheimertc.data.LoginResponse
|
import de.harheimertc.data.LoginResponse
|
||||||
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
|
|||||||
import de.harheimertc.data.RegistrationRequest
|
import de.harheimertc.data.RegistrationRequest
|
||||||
import de.harheimertc.data.ResetPasswordRequest
|
import de.harheimertc.data.ResetPasswordRequest
|
||||||
import de.harheimertc.data.SessionRefresher
|
import de.harheimertc.data.SessionRefresher
|
||||||
|
import io.sentry.Sentry
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
|
|||||||
private val sessionRefresher: SessionRefresher,
|
private val sessionRefresher: SessionRefresher,
|
||||||
) {
|
) {
|
||||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||||
val response = api.login(LoginRequest(email.trim(), password))
|
val endpoint = "api/auth/login"
|
||||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||||
|
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val body = response.errorBody()?.string().orEmpty()
|
||||||
|
val serverMessage = extractServerMessage(body)
|
||||||
|
val fallback = when (response.code()) {
|
||||||
|
401 -> "Ungueltige Anmeldedaten"
|
||||||
|
403 -> "Konto nicht freigeschaltet"
|
||||||
|
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
|
||||||
|
else -> "Anmeldung fehlgeschlagen"
|
||||||
|
}
|
||||||
|
val diagnostic = buildString {
|
||||||
|
append("\n\nDiagnose:\n")
|
||||||
|
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
|
||||||
|
append("Request: ").append(requestPreview).append('\n')
|
||||||
|
append("HTTP: ").append(response.code()).append('\n')
|
||||||
|
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
|
||||||
|
append("Server message: ").append(serverMessage ?: "none")
|
||||||
|
}
|
||||||
|
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
|
||||||
|
}
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||||
body
|
body
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "LoginRepository")
|
||||||
|
scope.setTag("operation", "login")
|
||||||
|
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout(): Result<Unit> = runCatching {
|
suspend fun logout(): Result<Unit> = runCatching {
|
||||||
@@ -41,27 +71,55 @@ class LoginRepository @Inject constructor(
|
|||||||
return@runCatching AuthStatusResponse()
|
return@runCatching AuthStatusResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = api.authStatus()
|
var response = retryOnNetworkFailure { api.authStatus() }
|
||||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
var status = response.body() ?: AuthStatusResponse()
|
var status = response.body() ?: AuthStatusResponse()
|
||||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||||
response = api.authStatus()
|
response = retryOnNetworkFailure { api.authStatus() }
|
||||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
status = response.body() ?: AuthStatusResponse()
|
status = response.body() ?: AuthStatusResponse()
|
||||||
}
|
}
|
||||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||||
status
|
status
|
||||||
|
}.onFailure { error ->
|
||||||
|
Sentry.withScope { scope ->
|
||||||
|
scope.setTag("repository", "LoginRepository")
|
||||||
|
scope.setTag("operation", "status")
|
||||||
|
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
|
||||||
|
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
|
||||||
|
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||||
|
Sentry.captureException(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.register(request)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
val response = api.register(request)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractServerMessage(raw: String): String? {
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
|
||||||
|
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maskEmail(rawEmail: String): String {
|
||||||
|
val email = rawEmail.trim()
|
||||||
|
if (!email.contains('@')) return "hidden"
|
||||||
|
val local = email.substringBefore('@')
|
||||||
|
val domain = email.substringAfter('@')
|
||||||
|
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
|
||||||
|
return "$localMasked@$domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt
Normal file → Executable file
18
android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt
Normal file → Executable file
@@ -1,6 +1,8 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -24,9 +26,19 @@ data class Mannschaft(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||||
val response = api.mannschaften(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
val response = api.mannschaften(season)
|
||||||
parseCsv(response.body()?.string().orEmpty())
|
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||||
|
parseCsv(response.body()?.string().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||||
|
|||||||
15
android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt
Normal file → Executable file
15
android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt
Normal file → Executable file
@@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse
|
|||||||
import de.harheimertc.data.MembersResponse
|
import de.harheimertc.data.MembersResponse
|
||||||
import de.harheimertc.data.NewsResponse
|
import de.harheimertc.data.NewsResponse
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.data.QttrValuesResponse
|
||||||
import de.harheimertc.data.SecureOfflineCache
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
|
|||||||
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun qttrValues(): Result<QttrValuesResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.qttrValues()
|
||||||
|
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putQttrValues,
|
||||||
|
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
|
||||||
|
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun members(): Result<MembersResponse> =
|
suspend fun members(): Result<MembersResponse> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
|
|||||||
response.body() ?: emptyMap()
|
response.body() ?: emptyMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
|
||||||
val response = api.deleteNews(id)
|
val response = api.deleteNews(id)
|
||||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt
Normal file → Executable file
33
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file
33
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
internal suspend fun <T> retryOnNetworkFailure(
|
||||||
|
retryDelayMillis: Long = 10_000L,
|
||||||
|
block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
if (!error.isRetryableNetworkError()) throw error
|
||||||
|
delay(retryDelayMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
|
||||||
|
is UnknownHostException,
|
||||||
|
is ConnectException,
|
||||||
|
is NoRouteToHostException,
|
||||||
|
is SocketTimeoutException,
|
||||||
|
is SSLException -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
16
android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt
Normal file → Executable file
16
android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt
Normal file → Executable file
@@ -8,9 +8,11 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||||
val response = api.publicNewsletterGroups()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
val response = api.publicNewsletterGroups()
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
||||||
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.confirmNewsletter(token)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
val response = api.confirmNewsletter(token)
|
||||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||||
|
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
||||||
|
)
|
||||||
110
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Normal file → Executable file
110
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Normal file → Executable file
@@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor(
|
|||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
retryOnNetworkFailure {
|
||||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||||
)
|
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
)
|
||||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||||
|
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||||
|
|
||||||
val credentialManager = CredentialManager.create(context)
|
val credentialManager = CredentialManager.create(context)
|
||||||
val credentialResponse = credentialManager.getCredential(
|
val credentialResponse = credentialManager.getCredential(
|
||||||
context = context,
|
context = context,
|
||||||
request = GetCredentialRequest(
|
request = GetCredentialRequest(
|
||||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||||
|
|
||||||
val response = api.passkeyLogin(
|
val response = api.passkeyLogin(
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||||
.put("client", "android")
|
.put("client", "android")
|
||||||
.put("deviceName", "Harheimer TC Android-App")
|
.put("deviceName", "Harheimer TC Android-App")
|
||||||
.toString()
|
.toString()
|
||||||
.toRequestBody(MediaTypes.json),
|
.toRequestBody(MediaTypes.json),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||||
body
|
body
|
||||||
|
}
|
||||||
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
||||||
|
|
||||||
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
||||||
val response = api.passkeys()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
val response = api.passkeys()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
||||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
retryOnNetworkFailure {
|
||||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||||
|
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||||
|
|
||||||
val credentialManager = CredentialManager.create(context)
|
val credentialManager = CredentialManager.create(context)
|
||||||
val credentialResponse = credentialManager.createCredential(
|
val credentialResponse = credentialManager.createCredential(
|
||||||
context = context,
|
context = context,
|
||||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||||
) as? CreatePublicKeyCredentialResponse
|
) as? CreatePublicKeyCredentialResponse
|
||||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||||
|
|
||||||
val response = api.registerPasskey(
|
val response = api.registerPasskey(
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||||
.put("name", name)
|
.put("name", name)
|
||||||
.put("client", "android")
|
.put("client", "android")
|
||||||
.toString()
|
.toString()
|
||||||
.toRequestBody(MediaTypes.json),
|
.toRequestBody(MediaTypes.json),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||||
response.body() ?: error("Leere Antwort")
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
||||||
|
|
||||||
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
||||||
|
|||||||
16
android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt
Normal file → Executable file
16
android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt
Normal file → Executable file
@@ -9,14 +9,18 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun load(): Result<ProfileResponse> = runCatching {
|
suspend fun load(): Result<ProfileResponse> = runCatching {
|
||||||
val response = api.profile()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
val response = api.profile()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
||||||
val response = api.updateProfile(request)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
val response = api.updateProfile(request)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt
Normal file → Executable file
66
android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt
Normal file → Executable file
@@ -31,43 +31,49 @@ data class MeisterschaftResult(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||||
val response = api.config()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
val response = api.config()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||||
val response = api.spielsysteme()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
val response = api.spielsysteme()
|
||||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||||
if (values.size < 8) return@mapNotNull null
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
Spielsystem(
|
if (values.size < 8) return@mapNotNull null
|
||||||
name = values[0],
|
Spielsystem(
|
||||||
description = values[1],
|
name = values[0],
|
||||||
teamSize = values[2],
|
description = values[1],
|
||||||
category = values[3],
|
teamSize = values[2],
|
||||||
sequence = values[5],
|
category = values[3],
|
||||||
gameCount = values[6],
|
sequence = values[5],
|
||||||
features = values[7],
|
gameCount = values[6],
|
||||||
)
|
features = values[7],
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||||
val response = api.vereinsmeisterschaften()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
val response = api.vereinsmeisterschaften()
|
||||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||||
if (values.size < 6) return@mapNotNull null
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
MeisterschaftResult(
|
if (values.size < 6) return@mapNotNull null
|
||||||
year = values[0],
|
MeisterschaftResult(
|
||||||
category = values[1],
|
year = values[0],
|
||||||
rank = values[2],
|
category = values[1],
|
||||||
playerOne = values[3],
|
rank = values[2],
|
||||||
playerTwo = values[4],
|
playerOne = values[3],
|
||||||
note = values[5],
|
playerTwo = values[4],
|
||||||
imageOne = values.getOrElse(6) { "" },
|
note = values[5],
|
||||||
imageTwo = values.getOrElse(7) { "" },
|
imageOne = values.getOrElse(6) { "" },
|
||||||
)
|
imageTwo = values.getOrElse(7) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt
Normal file → Executable file
24
android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt
Normal file → Executable file
@@ -9,18 +9,22 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||||
val response = api.spielplan(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplan(season)
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
body
|
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||||
val response = api.spielplanTable(team, season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplanTable(team, season)
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
body
|
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt
Normal file → Executable file
8
android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt
Normal file → Executable file
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||||
val response = api.termine()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.termine()
|
||||||
response.body()?.termine.orEmpty()
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt
Normal file → Executable file
8
android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt
Normal file → Executable file
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||||
val response = api.config()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
val response = api.config()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Normal file → Executable file
309
android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt
Normal file → Executable file
309
android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt
Normal file → Executable file
@@ -1,12 +1,14 @@
|
|||||||
package de.harheimertc.ui.components
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.R
|
import de.harheimertc.R
|
||||||
@@ -40,14 +43,17 @@ private enum class MenuSection {
|
|||||||
TRAINING,
|
TRAINING,
|
||||||
NEWSLETTER,
|
NEWSLETTER,
|
||||||
INTERN,
|
INTERN,
|
||||||
|
CMS,
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class MenuTarget(val label: String, val route: String)
|
private data class MenuTarget(val label: String, val route: String)
|
||||||
|
private const val LOGOUT_ROUTE = "__logout__"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigationHeader(
|
fun AppNavigationHeader(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit = {},
|
||||||
webTabletNavigation: Boolean = false,
|
webTabletNavigation: Boolean = false,
|
||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
@@ -59,9 +65,9 @@ fun AppNavigationHeader(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (webTabletNavigation) {
|
if (webTabletNavigation) {
|
||||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||||
} else {
|
} else {
|
||||||
CompactNavigation(selectedRoute, onNavigate, navigationState)
|
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,18 +76,79 @@ fun AppNavigationHeader(
|
|||||||
private fun CompactNavigation(
|
private fun CompactNavigation(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
val routeSection = menuSection(selectedRoute)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val section = routeSection ?: sectionOverride.value
|
||||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val subItems = submenu(section, navigationState)
|
||||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
|
||||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val mainScroll = rememberScrollState()
|
||||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
val subScroll = rememberScrollState()
|
||||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
|
||||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
BrandRow(
|
||||||
|
loggedIn = navigationState.loggedIn,
|
||||||
|
onLogin = { onNavigate(Destinations.Login.route) },
|
||||||
|
onLogout = onLogout,
|
||||||
|
)
|
||||||
|
|
||||||
|
ScrollableMenuRow(scrollState = mainScroll) {
|
||||||
|
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
|
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
|
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||||
|
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
|
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||||
|
if (navigationState.showGallery) {
|
||||||
|
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
}
|
}
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||||
|
if (navigationState.canAccessCms) {
|
||||||
|
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
}
|
||||||
|
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
|
subItems.forEach { item ->
|
||||||
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
|
onLogout()
|
||||||
|
} else {
|
||||||
|
onNavigate(item.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompactSectionLink(
|
||||||
|
label: String,
|
||||||
|
section: MenuSection,
|
||||||
|
activeSection: MenuSection?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (activeSection == section) Primary600 else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = modifier.clickable { onSelect() },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +156,13 @@ private fun CompactNavigation(
|
|||||||
private fun WebTabletNavigation(
|
private fun WebTabletNavigation(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
navigationState: NavigationUiState,
|
navigationState: NavigationUiState,
|
||||||
) {
|
) {
|
||||||
val section = menuSection(selectedRoute)
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
val section = sectionOverride.value ?: menuSection(selectedRoute)
|
||||||
// Helper that closes the CMS submenu when navigating away
|
val subScroll = rememberScrollState()
|
||||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Brand()
|
Brand()
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
@@ -103,75 +171,91 @@ private fun WebTabletNavigation(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
|
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
|
||||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
|
sectionOverride.value = null
|
||||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
|
onNavigate(Destinations.Home.route)
|
||||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
|
})
|
||||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
|
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
|
||||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.VereinAbout.route)
|
||||||
|
})
|
||||||
|
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.MANNSCHAFTEN
|
||||||
|
onNavigate(Destinations.Mannschaften.route)
|
||||||
|
})
|
||||||
|
MainLink("Training", section == MenuSection.TRAINING, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.TRAINING
|
||||||
|
onNavigate(Destinations.Training.route)
|
||||||
|
})
|
||||||
|
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Termine.route)
|
||||||
|
})
|
||||||
if (navigationState.showGallery) {
|
if (navigationState.showGallery) {
|
||||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.Gallery.route)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
|
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.NEWSLETTER
|
||||||
|
onNavigate(Destinations.NewsletterSubscribe.route)
|
||||||
|
})
|
||||||
if (navigationState.loggedIn) {
|
if (navigationState.loggedIn) {
|
||||||
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
|
MainLink("Intern", section == MenuSection.INTERN, onClick = {
|
||||||
}
|
sectionOverride.value = MenuSection.INTERN
|
||||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
|
})
|
||||||
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
if (navigationState.canAccessCms) {
|
||||||
}
|
MainLink("CMS", section == MenuSection.CMS, onClick = {
|
||||||
}
|
sectionOverride.value = MenuSection.CMS
|
||||||
val subItems = submenu(section, navigationState)
|
onNavigate(Destinations.Cms.route)
|
||||||
// determine CMS parent index and children
|
})
|
||||||
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
|
|
||||||
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
|
|
||||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
|
||||||
cmsExpanded.value = true
|
|
||||||
}
|
|
||||||
// First row: render all subitems but do NOT render CMS children here
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(top = 3.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
subItems.forEachIndexed { idx, item ->
|
|
||||||
if (idx == cmsIndex) {
|
|
||||||
// CMS parent toggle
|
|
||||||
SubLink(item.label, item.route == selectedRoute) {
|
|
||||||
cmsExpanded.value = !cmsExpanded.value
|
|
||||||
}
|
}
|
||||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
|
||||||
// skip cms children here; they'll be rendered in the second row when expanded
|
|
||||||
} else {
|
|
||||||
// normal item before CMS: close cms submenu on navigate
|
|
||||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
|
||||||
}
|
}
|
||||||
|
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Contact.route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Login.route)
|
||||||
|
}) { Text("Login", color = Color.White) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second row: when CMS expanded, render its children beneath
|
val subItems = submenu(section, navigationState)
|
||||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
Row(
|
subItems.forEach { item ->
|
||||||
modifier = Modifier
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
.fillMaxWidth()
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
.horizontalScroll(rememberScrollState())
|
onLogout()
|
||||||
.padding(top = 6.dp, bottom = 3.dp),
|
} else {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
onNavigate(item.route)
|
||||||
) {
|
}
|
||||||
cmsChildren.forEach { child ->
|
|
||||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BrandRow(onLogin: () -> Unit) {
|
private fun BrandRow(
|
||||||
|
loggedIn: Boolean,
|
||||||
|
onLogin: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Brand()
|
Brand()
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
if (loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +298,8 @@ private fun MainLink(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
|
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
@@ -229,23 +313,62 @@ private fun CompactLink(
|
|||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
beforeNavigate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
modifier = modifier.clickable { onNavigate(route) },
|
modifier = modifier.clickable {
|
||||||
|
beforeNavigate()
|
||||||
|
onNavigate(route)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScrollableMenuRow(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
topPadding: Dp = 0.dp,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = topPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (scrollState.canScrollBackward) "◀" else "",
|
||||||
|
color = Color(0xFFD4D4D8),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.horizontalScroll(scrollState),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (scrollState.canScrollForward) "▶" else "",
|
||||||
|
color = Color(0xFFD4D4D8),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -256,7 +379,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
@@ -287,14 +410,19 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.NewsletterConfirm.route,
|
Destinations.NewsletterConfirm.route,
|
||||||
Destinations.NewsletterConfirmed.route,
|
Destinations.NewsletterConfirmed.route,
|
||||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||||
|
|
||||||
Destinations.MemberArea.route,
|
Destinations.MemberArea.route,
|
||||||
Destinations.Members.route,
|
Destinations.Members.route,
|
||||||
|
Destinations.Qttr.route,
|
||||||
Destinations.MemberNews.route,
|
Destinations.MemberNews.route,
|
||||||
Destinations.Profile.route,
|
Destinations.Profile.route,
|
||||||
Destinations.MemberApi.route,
|
Destinations.NotificationSettings.route,
|
||||||
|
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||||
|
|
||||||
Destinations.CmsStartseite.route,
|
Destinations.CmsStartseite.route,
|
||||||
Destinations.CmsInhalte.route,
|
Destinations.CmsInhalte.route,
|
||||||
Destinations.CmsVereinsmeisterschaften.route,
|
Destinations.CmsVereinsmeisterschaften.route,
|
||||||
|
Destinations.CmsNews.route,
|
||||||
Destinations.CmsSportbetrieb.route,
|
Destinations.CmsSportbetrieb.route,
|
||||||
Destinations.CmsMitgliederverwaltung.route,
|
Destinations.CmsMitgliederverwaltung.route,
|
||||||
Destinations.CmsNewsletter.route,
|
Destinations.CmsNewsletter.route,
|
||||||
@@ -302,7 +430,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.CmsEinstellungen.route,
|
Destinations.CmsEinstellungen.route,
|
||||||
Destinations.CmsBenutzer.route,
|
Destinations.CmsBenutzer.route,
|
||||||
Destinations.CmsPasswordResetDiagnostics.route,
|
Destinations.CmsPasswordResetDiagnostics.route,
|
||||||
Destinations.Cms.route -> MenuSection.INTERN
|
Destinations.Cms.route -> MenuSection.CMS
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}.let { section ->
|
}.let { section ->
|
||||||
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
||||||
@@ -319,42 +448,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
MenuTarget("Links", Destinations.Links.route),
|
MenuTarget("Links", Destinations.Links.route),
|
||||||
MenuTarget("Impressum", Destinations.Impressum.route),
|
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.MANNSCHAFTEN -> listOf(
|
MenuSection.MANNSCHAFTEN -> listOf(
|
||||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.TRAINING -> listOf(
|
MenuSection.TRAINING -> listOf(
|
||||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.NEWSLETTER -> listOf(
|
MenuSection.NEWSLETTER -> listOf(
|
||||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||||
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.INTERN -> buildList {
|
MenuSection.INTERN -> buildList {
|
||||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||||
|
add(MenuTarget("QTTR", Destinations.Qttr.route))
|
||||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||||
|
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
|
}
|
||||||
// CMS child items (will be rendered when CMS parent is expanded)
|
|
||||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
|
MenuSection.CMS -> buildList {
|
||||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
if (state.canAccessFullCms) {
|
||||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||||
|
add(MenuTarget("News", Destinations.CmsNews.route))
|
||||||
|
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||||
|
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||||
|
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||||
|
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
|
||||||
|
}
|
||||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
|
||||||
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
|
|
||||||
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> emptyList()
|
null -> emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Normal file → Executable file
24
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file
24
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.theme.Primary600
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LoadingState(message: String = "Daten werden geladen...") {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Primary600)
|
||||||
|
Text(message, color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
0
android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Normal file → Executable file
11
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Normal file → Executable file
11
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Normal file → Executable file
@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
|
|||||||
object Links : Destinations("verein/links")
|
object Links : Destinations("verein/links")
|
||||||
object Impressum : Destinations("impressum")
|
object Impressum : Destinations("impressum")
|
||||||
object Mannschaften : Destinations("mannschaften")
|
object Mannschaften : Destinations("mannschaften")
|
||||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
|
||||||
fun create(slug: String): String = "mannschaften/$slug"
|
fun create(slug: String, season: String? = null): String {
|
||||||
|
val encodedSlug = android.net.Uri.encode(slug)
|
||||||
|
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
|
||||||
|
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||||
fun create(slug: String): String = "mannschaft/$slug"
|
fun create(slug: String): String = "mannschaft/$slug"
|
||||||
@@ -36,12 +40,15 @@ sealed class Destinations(val route: String) {
|
|||||||
object Register : Destinations("register")
|
object Register : Destinations("register")
|
||||||
object MemberArea : Destinations("intern")
|
object MemberArea : Destinations("intern")
|
||||||
object Members : Destinations("intern/mitglieder")
|
object Members : Destinations("intern/mitglieder")
|
||||||
|
object Qttr : Destinations("intern/qttr")
|
||||||
object MemberNews : Destinations("intern/news")
|
object MemberNews : Destinations("intern/news")
|
||||||
object Profile : Destinations("intern/profil")
|
object Profile : Destinations("intern/profil")
|
||||||
|
object NotificationSettings : Destinations("intern/benachrichtigungen")
|
||||||
object MemberApi : Destinations("intern/api")
|
object MemberApi : Destinations("intern/api")
|
||||||
object CmsStartseite : Destinations("cms/startseite")
|
object CmsStartseite : Destinations("cms/startseite")
|
||||||
object CmsInhalte : Destinations("cms/inhalte")
|
object CmsInhalte : Destinations("cms/inhalte")
|
||||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||||
|
object CmsNews : Destinations("cms/news")
|
||||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||||
object CmsNewsletter : Destinations("cms/newsletter")
|
object CmsNewsletter : Destinations("cms/newsletter")
|
||||||
|
|||||||
174
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Normal file → Executable file
174
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Normal file → Executable file
@@ -2,15 +2,25 @@ package de.harheimertc.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -26,7 +36,9 @@ fun NavGraph(
|
|||||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||||
val route = backStackEntry?.destination?.route
|
val route = backStackEntry?.destination?.route
|
||||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||||
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
|
backStackEntry.arguments?.getString("slug")?.let { slug ->
|
||||||
|
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
|
||||||
|
}
|
||||||
} else route
|
} else route
|
||||||
val navigationState by navigationViewModel.state.collectAsState()
|
val navigationState by navigationViewModel.state.collectAsState()
|
||||||
LaunchedEffect(currentRoute) {
|
LaunchedEffect(currentRoute) {
|
||||||
@@ -35,10 +47,27 @@ fun NavGraph(
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val persistentNavigation = maxWidth >= 600.dp
|
val persistentNavigation = maxWidth >= 600.dp
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
navigationState.connectionNote?.let { message ->
|
||||||
|
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = Color(0xFF7C2D12),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (persistentNavigation) {
|
if (persistentNavigation) {
|
||||||
AppNavigationHeader(
|
AppNavigationHeader(
|
||||||
selectedRoute = currentRoute,
|
selectedRoute = currentRoute,
|
||||||
onNavigate = navController::navigateTopLevel,
|
onNavigate = navController::navigateTopLevel,
|
||||||
|
onLogout = {
|
||||||
|
navigationViewModel.logout {
|
||||||
|
navController.navigate(Destinations.Home.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
webTabletNavigation = true,
|
webTabletNavigation = true,
|
||||||
navigationState = navigationState,
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
@@ -52,6 +81,8 @@ fun NavGraph(
|
|||||||
de.harheimertc.ui.screens.home.HomeScreen(
|
de.harheimertc.ui.screens.home.HomeScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showNavigationHeader = !persistentNavigation,
|
showNavigationHeader = !persistentNavigation,
|
||||||
|
navigationViewModel = navigationViewModel,
|
||||||
|
viewModel = hiltViewModel(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.VereinAbout.route) {
|
composable(Destinations.VereinAbout.route) {
|
||||||
@@ -111,9 +142,13 @@ fun NavGraph(
|
|||||||
composable("mannschaften/jugend") {
|
composable("mannschaften/jugend") {
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||||
}
|
}
|
||||||
composable(Destinations.MannschaftDetail.route) { entry ->
|
composable(
|
||||||
|
route = Destinations.MannschaftDetail.route,
|
||||||
|
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
|
||||||
|
) { entry ->
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
|
season = entry.arguments?.getString("season"),
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
@@ -121,6 +156,7 @@ fun NavGraph(
|
|||||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
|
season = null,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
@@ -253,6 +289,7 @@ fun NavGraph(
|
|||||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.Members.route) {
|
composable(Destinations.Members.route) {
|
||||||
@@ -261,6 +298,12 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.Qttr.route) {
|
||||||
|
de.harheimertc.ui.screens.memberarea.QttrScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Destinations.MemberNews.route) {
|
composable(Destinations.MemberNews.route) {
|
||||||
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -273,6 +316,13 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.NotificationSettings.route) {
|
||||||
|
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
navigationState = navigationState,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Destinations.MemberApi.route) {
|
composable(Destinations.MemberApi.route) {
|
||||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -280,43 +330,141 @@ fun NavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsStartseite.route) {
|
composable(Destinations.CmsStartseite.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsInhalte.route) {
|
composable(Destinations.CmsInhalte.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(Destinations.CmsNews.route) {
|
||||||
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsSportbetrieb.route) {
|
composable(Destinations.CmsSportbetrieb.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsMitgliederverwaltung.route) {
|
composable(Destinations.CmsMitgliederverwaltung.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsNewsletter.route) {
|
composable(Destinations.CmsNewsletter.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessNewsletter,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsContactRequests.route) {
|
composable(Destinations.CmsContactRequests.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessContactRequests,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsEinstellungen.route) {
|
composable(Destinations.CmsEinstellungen.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsBenutzer.route) {
|
composable(Destinations.CmsBenutzer.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessFullCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Destinations.Cms.route) {
|
composable(Destinations.Cms.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
RequireCmsAccess(
|
||||||
|
allowed = navigationState.canAccessCms,
|
||||||
|
navController = navController,
|
||||||
|
) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequireCmsAccess(
|
||||||
|
allowed: Boolean,
|
||||||
|
navController: NavHostController,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
if (allowed) {
|
||||||
|
content()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Kein Zugriff auf diesen Bereich.",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = Color(0xFF7F1D1D),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Bitte mit einem berechtigten Konto anmelden.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF7C2D12),
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
|
||||||
|
Text("Zum Login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NavHostController.navigateTopLevel(route: String) {
|
private fun NavHostController.navigateTopLevel(route: String) {
|
||||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||||
route != Destinations.Spielsysteme.route
|
route != Destinations.Spielsysteme.route
|
||||||
|
|||||||
58
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt
Normal file → Executable file
58
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt
Normal file → Executable file
@@ -3,10 +3,13 @@ package de.harheimertc.ui.navigation
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
import de.harheimertc.repositories.AuthRepository
|
||||||
import de.harheimertc.repositories.GalleryRepository
|
import de.harheimertc.repositories.GalleryRepository
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -18,10 +21,13 @@ data class NavigationUiState(
|
|||||||
val hasGalleryImages: Boolean = false,
|
val hasGalleryImages: Boolean = false,
|
||||||
val loggedIn: Boolean = false,
|
val loggedIn: Boolean = false,
|
||||||
val roles: Set<String> = emptySet(),
|
val roles: Set<String> = emptySet(),
|
||||||
|
val connectionNote: String? = null,
|
||||||
) {
|
) {
|
||||||
val isAdmin: Boolean get() = "admin" in roles
|
val isAdmin: Boolean get() = "admin" in roles
|
||||||
|
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||||
|
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
|
||||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +36,24 @@ class NavigationViewModel @Inject constructor(
|
|||||||
private val mannschaftenRepository: MannschaftenRepository,
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
private val galleryRepository: GalleryRepository,
|
private val galleryRepository: GalleryRepository,
|
||||||
private val loginRepository: LoginRepository,
|
private val loginRepository: LoginRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
private val pushTokenRepository: PushTokenRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NavigationUiState())
|
private val _state = MutableStateFlow(NavigationUiState())
|
||||||
val state: StateFlow<NavigationUiState> = _state
|
val state: StateFlow<NavigationUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadNavigationData()
|
loadNavigationData()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
|
||||||
|
)
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadNavigationData() {
|
fun loadNavigationData() {
|
||||||
@@ -44,22 +62,54 @@ class NavigationViewModel @Inject constructor(
|
|||||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||||
val status = auth.await()
|
val status = auth.await()
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||||
_state.value = NavigationUiState(
|
_state.value = NavigationUiState(
|
||||||
teams = teams.await(),
|
teams = teams.await(),
|
||||||
hasGalleryImages = gallery.await(),
|
hasGalleryImages = gallery.await(),
|
||||||
loggedIn = status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = null,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshSession() {
|
fun refreshSession() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
loggedIn = status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPushToken() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
pushTokenRepository.registerCurrentDevice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onComplete: () -> Unit = {}) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loginRepository.logout()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loggedIn = false,
|
||||||
|
roles = emptySet(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
|
)
|
||||||
|
onComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
|
||||||
|
addAll(roles)
|
||||||
|
role?.takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
addAll(user?.roles.orEmpty())
|
||||||
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsBenutzerScreens.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsBenutzerScreens.kt
Normal file → Executable file
46
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Normal file → Executable file
46
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Normal file → Executable file
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -49,7 +49,7 @@ import java.util.Locale
|
|||||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var selection by remember { mutableStateOf(setOf<Int>()) }
|
var selection by remember { mutableStateOf(setOf<String>()) }
|
||||||
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||||
val loginState by loginVm.state.collectAsState()
|
val loginState by loginVm.state.collectAsState()
|
||||||
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||||
@@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
|
|
||||||
// Local dialog state for create/edit + delete confirmation (hoisted)
|
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||||
var dialogOpen by remember { mutableStateOf(false) }
|
var dialogOpen by remember { mutableStateOf(false) }
|
||||||
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
|
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
|
||||||
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var content by remember { mutableStateOf("") }
|
var content by remember { mutableStateOf("") }
|
||||||
@@ -112,7 +112,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||||
if (state.loading) item { CircularProgressIndicator() }
|
if (state.loading) item { LoadingState("News werden geladen...") }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||||
@@ -132,15 +132,22 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
|
|
||||||
items(state.news) { news ->
|
items(state.news) { news ->
|
||||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||||
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
NewsListItem(
|
||||||
id?.let {
|
news = news,
|
||||||
selection = if (sel) selection + it else selection - it
|
canWrite = canWrite,
|
||||||
}
|
selected = selected,
|
||||||
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
onSelect = { id, sel ->
|
||||||
|
id?.let {
|
||||||
|
selection = if (sel) selection + it else selection - it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEdit = { openEdit(news) },
|
||||||
|
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bulk action bar
|
// bulk action bar
|
||||||
if (selection.isNotEmpty()) {
|
if (canWrite && selection.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||||
@@ -255,16 +262,21 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NewsListItem(
|
private fun NewsListItem(
|
||||||
news: NewsDto,
|
news: NewsDto,
|
||||||
|
canWrite: Boolean,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
|
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||||
onEdit: (NewsDto) -> Unit,
|
onEdit: (NewsDto) -> Unit,
|
||||||
onDelete: (Int) -> Unit,
|
onDelete: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = { onSelect(news.id, it) },
|
||||||
|
enabled = canWrite,
|
||||||
|
)
|
||||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||||
if (news.isPublic) {
|
if (news.isPublic) {
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||||
@@ -297,9 +309,11 @@ private fun NewsListItem(
|
|||||||
Text("Aktualisiert: ${news.updated}")
|
Text("Aktualisiert: ${news.updated}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row {
|
if (canWrite) {
|
||||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
Row {
|
||||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||||
|
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
672
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Normal file → Executable file
672
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Normal file → Executable file
@@ -1,8 +1,10 @@
|
|||||||
package de.harheimertc.ui.screens.cms
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.app.DatePickerDialog
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -16,7 +18,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -37,6 +38,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -49,9 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
|
|||||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.PasswordResetStepDto
|
import de.harheimertc.data.PasswordResetStepDto
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.repositories.CmsMannschaftRow
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
@@ -61,13 +67,14 @@ import de.harheimertc.ui.theme.Primary100
|
|||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
|
||||||
item { CmsSummaryGrid(navController, state) }
|
item { CmsSummaryGrid(navController, state) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +92,7 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
|
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -171,7 +178,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
@@ -262,7 +269,7 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
|
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
|
||||||
when {
|
when {
|
||||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -433,116 +440,426 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
@Composable
|
@Composable
|
||||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val config = state.config
|
val context = LocalContext.current
|
||||||
var ortName by remember { mutableStateOf("") }
|
var activeTab by remember { mutableStateOf("termine") }
|
||||||
var ortStrasse by remember { mutableStateOf("") }
|
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
|
||||||
var ortPlz by remember { mutableStateOf("") }
|
var spielplanCsv by remember { mutableStateOf("") }
|
||||||
var ortOrt by remember { mutableStateOf("") }
|
var spielplanEditorOpen by remember { mutableStateOf(false) }
|
||||||
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
var terminDialogOpen by remember { mutableStateOf(false) }
|
||||||
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
|
||||||
|
var terminDatum by remember { mutableStateOf("") }
|
||||||
|
var terminUhrzeit by remember { mutableStateOf("") }
|
||||||
|
var terminTitel by remember { mutableStateOf("") }
|
||||||
|
var terminBeschreibung by remember { mutableStateOf("") }
|
||||||
|
var terminKategorie by remember { mutableStateOf("Sonstiges") }
|
||||||
|
var terminKategorieOpen by remember { mutableStateOf(false) }
|
||||||
|
val tabs = listOf(
|
||||||
|
"termine" to "Termine",
|
||||||
|
"mannschaften" to "Mannschaften",
|
||||||
|
"spielplaene" to "Spielpläne",
|
||||||
|
)
|
||||||
|
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||||
|
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||||
|
}
|
||||||
|
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
LaunchedEffect(Unit) {
|
||||||
config?.let {
|
viewModel.loadSportbetrieb()
|
||||||
ortName = it.training.ort.name
|
|
||||||
ortStrasse = it.training.ort.strasse
|
|
||||||
ortPlz = it.training.ort.plz
|
|
||||||
ortOrt = it.training.ort.ort
|
|
||||||
trainingTimes.clear()
|
|
||||||
trainingTimes.addAll(it.training.zeiten)
|
|
||||||
trainers.clear()
|
|
||||||
trainers.addAll(it.trainer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
LaunchedEffect(state.sportMannschaften) {
|
||||||
when {
|
mannschaften.clear()
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
mannschaften.addAll(state.sportMannschaften)
|
||||||
else -> {
|
}
|
||||||
item {
|
|
||||||
Button(
|
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||||
onClick = {
|
spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||||
viewModel.saveConfig(
|
}
|
||||||
config.copy(
|
|
||||||
training = config.training.copy(
|
fun openTerminDialog(termin: TerminDto?) {
|
||||||
ort = config.training.ort.copy(
|
editingTermin = termin
|
||||||
name = ortName,
|
terminDatum = termin?.datum.orEmpty()
|
||||||
strasse = ortStrasse,
|
terminUhrzeit = termin?.uhrzeit.orEmpty()
|
||||||
plz = ortPlz,
|
terminTitel = termin?.titel.orEmpty()
|
||||||
ort = ortOrt,
|
terminBeschreibung = termin?.beschreibung.orEmpty()
|
||||||
),
|
terminKategorie = termin?.kategorie ?: "Sonstiges"
|
||||||
zeiten = trainingTimes.toList(),
|
terminDialogOpen = true
|
||||||
),
|
}
|
||||||
trainer = trainers.toList(),
|
|
||||||
),
|
fun openDatePicker() {
|
||||||
)
|
val calendar = Calendar.getInstance()
|
||||||
},
|
runCatching {
|
||||||
enabled = !state.saving,
|
val parts = terminDatum.split("-")
|
||||||
modifier = Modifier.fillMaxWidth(),
|
if (parts.size == 3) {
|
||||||
) {
|
calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
|
||||||
Text(if (state.saving) "Speichert..." else "Speichern")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainingsort") {
|
|
||||||
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainingszeiten") {
|
|
||||||
trainingTimes.forEachIndexed { index, zeit ->
|
|
||||||
TrainingTimeEditorCard(
|
|
||||||
zeit = zeit,
|
|
||||||
onChange = { updated -> trainingTimes[index] = updated },
|
|
||||||
onRemove = { trainingTimes.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainingTimes.add(
|
|
||||||
de.harheimertc.data.TrainingTimeDto(
|
|
||||||
id = "training-${System.currentTimeMillis()}",
|
|
||||||
tag = "Montag",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainingszeit hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainer") {
|
|
||||||
trainers.forEachIndexed { index, trainer ->
|
|
||||||
TrainerEditorCard(
|
|
||||||
trainer = trainer,
|
|
||||||
onChange = { updated -> trainers[index] = updated },
|
|
||||||
onRemove = { trainers.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainers.add(
|
|
||||||
de.harheimertc.data.TrainerDto(
|
|
||||||
id = "trainer-${System.currentTimeMillis()}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainer hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { FormMessages(state.error, state.message) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DatePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, year, month, day ->
|
||||||
|
terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day)
|
||||||
|
},
|
||||||
|
calendar.get(Calendar.YEAR),
|
||||||
|
calendar.get(Calendar.MONTH),
|
||||||
|
calendar.get(Calendar.DAY_OF_MONTH),
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") {
|
||||||
|
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
tabs.forEach { (id, label) ->
|
||||||
|
if (activeTab == id) {
|
||||||
|
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.sportLoading) {
|
||||||
|
when (activeTab) {
|
||||||
|
"termine" -> {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = { openTerminDialog(null) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Termin hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.sportTermine.isEmpty()) {
|
||||||
|
item { EmptyCard("Keine Termine gefunden.") }
|
||||||
|
}
|
||||||
|
items(state.sportTermine.size) { index ->
|
||||||
|
val termin = state.sportTermine[index]
|
||||||
|
DataCard(termin.titel.ifBlank { "Termin" }) {
|
||||||
|
InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" "))
|
||||||
|
InfoRow("Kategorie", termin.kategorie ?: "Sonstiges")
|
||||||
|
if (!termin.beschreibung.isNullOrBlank()) {
|
||||||
|
Text(termin.beschreibung, color = Accent700)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Bearbeiten")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.deleteSportTermin(termin) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Löschen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"mannschaften" -> {
|
||||||
|
item {
|
||||||
|
if (state.sportMannschaftenSeasons.isNotEmpty()) {
|
||||||
|
DataCard("Saison") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
state.sportMannschaftenSeasons.forEach { season ->
|
||||||
|
if (season == state.sportMannschaftenSeason) {
|
||||||
|
Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.loadSportMannschaftenSeason(season) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(season)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Hinzufügen")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mannschaften.isEmpty()) {
|
||||||
|
item { EmptyCard("Keine Mannschaften gefunden.") }
|
||||||
|
}
|
||||||
|
items(mannschaften.size) { index ->
|
||||||
|
MannschaftEditorCard(
|
||||||
|
row = mannschaften[index],
|
||||||
|
spielplanTeams = spielplanTeamOptions,
|
||||||
|
onChange = { updated -> mannschaften[index] = updated },
|
||||||
|
onRemove = { mannschaften.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"spielplaene" -> {
|
||||||
|
item {
|
||||||
|
DataCard("Vereins-Spielplan (CSV)") {
|
||||||
|
val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" }
|
||||||
|
val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv"
|
||||||
|
InfoRow("Datei", fileName)
|
||||||
|
InfoRow("Saison", seasonLabel)
|
||||||
|
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.importSportSpielplan() },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Importiert..." else "Von myTischtennis importieren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.loadSportbetrieb() },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Neu laden")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { spielplanEditorOpen = true },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("CSV bearbeiten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { FormMessages(state.error, state.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminDialogOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { terminDialogOpen = false },
|
||||||
|
title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(terminDatum.ifBlank { "Datum auswählen" })
|
||||||
|
}
|
||||||
|
OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(terminKategorie.ifBlank { "Kategorie auswählen" })
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) {
|
||||||
|
terminKategorien.forEach { kategorie ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(kategorie) },
|
||||||
|
onClick = {
|
||||||
|
terminKategorie = kategorie
|
||||||
|
terminKategorieOpen = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.saveSportTermin(
|
||||||
|
editingTermin,
|
||||||
|
TerminDto(
|
||||||
|
datum = terminDatum,
|
||||||
|
uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() },
|
||||||
|
titel = terminTitel,
|
||||||
|
beschreibung = terminBeschreibung.takeIf { it.isNotBlank() },
|
||||||
|
kategorie = terminKategorie.ifBlank { "Sonstiges" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
terminDialogOpen = false
|
||||||
|
},
|
||||||
|
enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spielplanEditorOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { spielplanEditorOpen = false },
|
||||||
|
title = { Text("Spielplan CSV bearbeiten") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = spielplanCsv,
|
||||||
|
onValueChange = { spielplanCsv = it },
|
||||||
|
label = { Text("CSV mit Semikolon") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 12,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val (headers, rows) = parseSportCsvText(spielplanCsv)
|
||||||
|
viewModel.saveSportSpielplan(headers, rows)
|
||||||
|
spielplanEditorOpen = false
|
||||||
|
},
|
||||||
|
enabled = !state.sportSaving && spielplanCsv.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MannschaftEditorCard(
|
||||||
|
row: CmsMannschaftRow,
|
||||||
|
spielplanTeams: List<ImportedSpielplanTeam>,
|
||||||
|
onChange: (CmsMannschaftRow) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
|
||||||
|
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
||||||
|
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
if (spielplanTeams.isNotEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Aus importierten Teams auswählen")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
|
||||||
|
spielplanTeams.forEach { team ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(team.label) },
|
||||||
|
onClick = {
|
||||||
|
onChange(row.copy(spielplanMannschaftId = team.id))
|
||||||
|
teamPickerOpen = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||||
|
OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Entfernen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ImportedSpielplanTeam(val id: String, val label: String)
|
||||||
|
|
||||||
|
private fun importedSpielplanTeams(headers: List<String>, rows: List<List<String>>): List<ImportedSpielplanTeam> {
|
||||||
|
fun index(name: String) = headers.indexOf(name)
|
||||||
|
fun value(row: List<String>, column: String): String {
|
||||||
|
val columnIndex = index(column)
|
||||||
|
return row.getOrElse(columnIndex) { "" }.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
val teams = linkedMapOf<String, ImportedSpielplanTeam>()
|
||||||
|
listOf("Heim", "Gast").forEach { side ->
|
||||||
|
rows.forEach { row ->
|
||||||
|
val isHarheimer = value(row, "${side}VereinNr") == "43030" || value(row, "${side}VereinName") == "Harheimer TC"
|
||||||
|
val id = value(row, "${side}MannschaftId")
|
||||||
|
if (!isHarheimer || id.isBlank()) return@forEach
|
||||||
|
|
||||||
|
val teamName = value(row, "${side}Mannschaft")
|
||||||
|
val ageClass = value(row, "${side}MannschaftAltersklasse")
|
||||||
|
val teamNumber = value(row, "${side}MannschaftNr")
|
||||||
|
val label = listOf(teamName, ageClass, teamNumber.takeIf { it.isNotBlank() }?.let { "Nr. $it" })
|
||||||
|
.filterNotNull()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" · ")
|
||||||
|
teams.putIfAbsent(id, ImportedSpielplanTeam(id, "$label [$id]"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return teams.values.sortedBy { it.label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
|
||||||
|
if (headers.isEmpty()) return ""
|
||||||
|
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
|
||||||
|
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
|
||||||
|
if (lines.isEmpty()) return emptyList<String>() to emptyList()
|
||||||
|
val headers = parseDelimitedLine(lines.first(), ';')
|
||||||
|
val rows = lines.drop(1).map { parseDelimitedLine(it, ';') }
|
||||||
|
return headers to rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDelimitedLine(line: String, delimiter: Char): List<String> {
|
||||||
|
val values = mutableListOf<String>()
|
||||||
|
val value = StringBuilder()
|
||||||
|
var quoted = false
|
||||||
|
var index = 0
|
||||||
|
while (index < line.length) {
|
||||||
|
when (val char = line[index]) {
|
||||||
|
'"' -> {
|
||||||
|
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||||
|
value.append('"')
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
quoted = !quoted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delimiter -> if (quoted) value.append(char) else {
|
||||||
|
values += value.toString()
|
||||||
|
value.clear()
|
||||||
|
}
|
||||||
|
else -> value.append(char)
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
values += value.toString()
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.csvCell(delimiter: String): String {
|
||||||
|
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||||
|
val escaped = replace("\"", "\"\"")
|
||||||
|
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -555,7 +872,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
|
|||||||
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
|
||||||
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
||||||
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
||||||
}
|
}
|
||||||
@@ -592,7 +909,7 @@ fun CmsNewsletterScreen(
|
|||||||
var grpTargetGroup by remember { mutableStateOf("") }
|
var grpTargetGroup by remember { mutableStateOf("") }
|
||||||
var grpSendToExternal by remember { mutableStateOf(true) }
|
var grpSendToExternal by remember { mutableStateOf(true) }
|
||||||
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
|
||||||
item {
|
item {
|
||||||
if (canWrite) Button(onClick = {
|
if (canWrite) Button(onClick = {
|
||||||
editingNewsletter = null
|
editingNewsletter = null
|
||||||
@@ -619,11 +936,13 @@ fun CmsNewsletterScreen(
|
|||||||
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
||||||
items(state.newsletters.size) { index ->
|
items(state.newsletters.size) { index ->
|
||||||
val item = state.newsletters[index]
|
val item = state.newsletters[index]
|
||||||
NewsletterCard(item,
|
NewsletterCard(
|
||||||
|
item,
|
||||||
|
canWrite = canWrite,
|
||||||
onEdit = { nl ->
|
onEdit = { nl ->
|
||||||
editingNewsletter = nl
|
editingNewsletter = nl
|
||||||
nlTitle = nl.title
|
nlTitle = nl.title
|
||||||
nlContent = nl.title ?: ""
|
nlContent = nl.title
|
||||||
nlType = "subscription"
|
nlType = "subscription"
|
||||||
nlTargetGroup = ""
|
nlTargetGroup = ""
|
||||||
nlSendToExternal = true
|
nlSendToExternal = true
|
||||||
@@ -637,7 +956,9 @@ fun CmsNewsletterScreen(
|
|||||||
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
||||||
items(state.newsletterGroups.size) { index ->
|
items(state.newsletterGroups.size) { index ->
|
||||||
val group = state.newsletterGroups[index]
|
val group = state.newsletterGroups[index]
|
||||||
NewsletterGroupCard(group,
|
NewsletterGroupCard(
|
||||||
|
group,
|
||||||
|
canWrite = canWrite,
|
||||||
onEdit = { g ->
|
onEdit = { g ->
|
||||||
editingGroup = g
|
editingGroup = g
|
||||||
grpName = g.name
|
grpName = g.name
|
||||||
@@ -753,6 +1074,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
var websiteVorname by remember { mutableStateOf("") }
|
var websiteVorname by remember { mutableStateOf("") }
|
||||||
var websiteNachname by remember { mutableStateOf("") }
|
var websiteNachname by remember { mutableStateOf("") }
|
||||||
var websiteEmail by remember { mutableStateOf("") }
|
var websiteEmail by remember { mutableStateOf("") }
|
||||||
|
var ortName by remember { mutableStateOf("") }
|
||||||
|
var ortStrasse by remember { mutableStateOf("") }
|
||||||
|
var ortPlz by remember { mutableStateOf("") }
|
||||||
|
var ortOrt by remember { mutableStateOf("") }
|
||||||
|
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
||||||
|
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
LaunchedEffect(config) {
|
||||||
config?.let {
|
config?.let {
|
||||||
@@ -764,12 +1091,20 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
websiteVorname = it.website.verantwortlicher.vorname
|
websiteVorname = it.website.verantwortlicher.vorname
|
||||||
websiteNachname = it.website.verantwortlicher.nachname
|
websiteNachname = it.website.verantwortlicher.nachname
|
||||||
websiteEmail = it.website.verantwortlicher.email
|
websiteEmail = it.website.verantwortlicher.email
|
||||||
|
ortName = it.training.ort.name
|
||||||
|
ortStrasse = it.training.ort.strasse
|
||||||
|
ortPlz = it.training.ort.plz
|
||||||
|
ortOrt = it.training.ort.ort
|
||||||
|
trainingTimes.clear()
|
||||||
|
trainingTimes.addAll(it.training.zeiten)
|
||||||
|
trainers.clear()
|
||||||
|
trainers.addAll(it.trainer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
|
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -790,6 +1125,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
email = websiteEmail,
|
email = websiteEmail,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
training = config.training.copy(
|
||||||
|
ort = config.training.ort.copy(
|
||||||
|
name = ortName,
|
||||||
|
strasse = ortStrasse,
|
||||||
|
plz = ortPlz,
|
||||||
|
ort = ortOrt,
|
||||||
|
),
|
||||||
|
zeiten = trainingTimes.toList(),
|
||||||
|
),
|
||||||
|
trainer = trainers.toList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -822,6 +1167,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainingsort") {
|
||||||
|
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||||
|
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainingszeiten") {
|
||||||
|
trainingTimes.forEachIndexed { index, zeit ->
|
||||||
|
TrainingTimeEditorCard(
|
||||||
|
zeit = zeit,
|
||||||
|
onChange = { updated -> trainingTimes[index] = updated },
|
||||||
|
onRemove = { trainingTimes.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
trainingTimes.add(
|
||||||
|
de.harheimertc.data.TrainingTimeDto(
|
||||||
|
id = "training-${System.currentTimeMillis()}",
|
||||||
|
tag = "Montag",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Trainingszeit hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainer") {
|
||||||
|
trainers.forEachIndexed { index, trainer ->
|
||||||
|
TrainerEditorCard(
|
||||||
|
trainer = trainer,
|
||||||
|
onChange = { updated -> trainers[index] = updated },
|
||||||
|
onRemove = { trainers.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
trainers.add(
|
||||||
|
de.harheimertc.data.TrainerDto(
|
||||||
|
id = "trainer-${System.currentTimeMillis()}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Trainer hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
DataCard("Systemstatus") {
|
DataCard("Systemstatus") {
|
||||||
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
||||||
@@ -883,7 +1285,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
item { CircularProgressIndicator(color = Primary600) }
|
item { LoadingState("Diagnosedaten werden geladen...") }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.passwordResetSearchTerm.isNotBlank()) {
|
if (state.passwordResetSearchTerm.isNotBlank()) {
|
||||||
@@ -950,6 +1352,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
|||||||
val cards = listOf(
|
val cards = listOf(
|
||||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
||||||
|
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
|
||||||
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||||
@@ -1073,7 +1476,7 @@ private fun CmsConfigPage(
|
|||||||
) {
|
) {
|
||||||
CmsPage(navController, showBackNavigation, title, subtitle) {
|
CmsPage(navController, showBackNavigation, title, subtitle) {
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
item { CircularProgressIndicator(color = Primary600) }
|
item { LoadingState("Konfiguration wird geladen...") }
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
@@ -1229,29 +1632,44 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
|
private fun NewsletterCard(
|
||||||
|
newsletter: NewsletterDto,
|
||||||
|
canWrite: Boolean,
|
||||||
|
onEdit: (NewsletterDto) -> Unit = {},
|
||||||
|
onDelete: (String) -> Unit = {},
|
||||||
|
onSend: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
||||||
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
||||||
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
||||||
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
||||||
Row {
|
if (canWrite) {
|
||||||
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
Row {
|
||||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
||||||
if (newsletter.status != "sent") {
|
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
if (newsletter.status != "sent") {
|
||||||
|
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) {
|
private fun NewsletterGroupCard(
|
||||||
|
group: NewsletterGroupDto,
|
||||||
|
canWrite: Boolean,
|
||||||
|
onEdit: (NewsletterGroupDto) -> Unit = {},
|
||||||
|
onDelete: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
DataCard(group.name.ifBlank { group.id }) {
|
DataCard(group.name.ifBlank { group.id }) {
|
||||||
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
||||||
InfoRow("Abonnenten", group.subscribers.size.toString())
|
InfoRow("Abonnenten", group.subscribers.size.toString())
|
||||||
Row {
|
if (canWrite) {
|
||||||
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
Row {
|
||||||
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
||||||
|
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
225
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Normal file → Executable file
225
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Normal file → Executable file
@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
import de.harheimertc.data.CmsUserDto
|
import de.harheimertc.data.CmsUserDto
|
||||||
import de.harheimertc.data.ConfigResponse
|
import de.harheimertc.data.ConfigResponse
|
||||||
import de.harheimertc.data.ContactRequestDto
|
import de.harheimertc.data.ContactRequestDto
|
||||||
@@ -12,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
|||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
|
import de.harheimertc.data.SpielDto
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
|
import de.harheimertc.repositories.CmsMannschaftRow
|
||||||
import de.harheimertc.repositories.CmsRepository
|
import de.harheimertc.repositories.CmsRepository
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -38,17 +43,37 @@ data class CmsUiState(
|
|||||||
val passwordResetFailedOnly: Boolean = true,
|
val passwordResetFailedOnly: Boolean = true,
|
||||||
val news: List<NewsDto> = emptyList(),
|
val news: List<NewsDto> = emptyList(),
|
||||||
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||||
|
val sportLoading: Boolean = false,
|
||||||
|
val sportSaving: Boolean = false,
|
||||||
|
val sportTermine: List<TerminDto> = emptyList(),
|
||||||
|
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
|
||||||
|
val sportMannschaftenSeasons: List<String> = emptyList(),
|
||||||
|
val sportMannschaftenSeason: String = "",
|
||||||
|
val sportSpielplanHeaders: List<String> = emptyList(),
|
||||||
|
val sportSpielplanRows: List<List<String>> = emptyList(),
|
||||||
|
val sportSpielplanSeason: String = "",
|
||||||
|
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CmsViewModel @Inject constructor(
|
class CmsViewModel @Inject constructor(
|
||||||
private val repository: CmsRepository,
|
private val repository: CmsRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(CmsUiState())
|
private val _state = MutableStateFlow(CmsUiState())
|
||||||
val state: StateFlow<CmsUiState> = _state
|
val state: StateFlow<CmsUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
@@ -168,6 +193,183 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadSportbetrieb() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
|
||||||
|
|
||||||
|
val termineRes = async { repository.managedTermine() }
|
||||||
|
val seasonsRes = async { repository.mannschaftenSeasons() }
|
||||||
|
val spielplanRes = async { repository.spielplan() }
|
||||||
|
|
||||||
|
val termineResult = termineRes.await()
|
||||||
|
val seasonsResult = seasonsRes.await()
|
||||||
|
val seasonInfo = seasonsResult.getOrNull()
|
||||||
|
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
|
||||||
|
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
|
||||||
|
val spielplanResult = spielplanRes.await()
|
||||||
|
val errors = listOfNotNull(
|
||||||
|
ErrorMapper.mapError(termineResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
|
||||||
|
)
|
||||||
|
val spielplan = spielplanResult.getOrNull()
|
||||||
|
val headers = spielplan?.headers.orEmpty()
|
||||||
|
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportLoading = false,
|
||||||
|
sportTermine = termineResult.getOrNull().orEmpty(),
|
||||||
|
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
|
||||||
|
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
|
||||||
|
sportMannschaftenSeason = selectedSeason,
|
||||||
|
sportSpielplanHeaders = headers,
|
||||||
|
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
|
||||||
|
sportSpielplanSeason = spielplan?.season.orEmpty(),
|
||||||
|
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
|
||||||
|
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSportMannschaftenSeason(season: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
|
||||||
|
repository.mannschaften(season)
|
||||||
|
.onSuccess { rows ->
|
||||||
|
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportLoading = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
if (original != null) {
|
||||||
|
repository.deleteTermin(original)
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val saveResult = repository.saveTermin(termin)
|
||||||
|
saveResult
|
||||||
|
.onSuccess { response ->
|
||||||
|
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportTermine = termine,
|
||||||
|
message = response.message ?: "Termin gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSportTermin(termin: TerminDto) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.deleteTermin(termin)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportTermine = _state.value.sportTermine.filterNot { it == termin },
|
||||||
|
message = response.message ?: "Termin gelöscht.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportMannschaften = rows,
|
||||||
|
message = response.message ?: "Mannschaften gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importSportSpielplan() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.importSpielplan()
|
||||||
|
.onSuccess { response ->
|
||||||
|
if (!response.success) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = response.message ?: "Spielplan konnte nicht importiert werden.",
|
||||||
|
)
|
||||||
|
return@onSuccess
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
message = response.message ?: "Spielplan aktualisiert.",
|
||||||
|
)
|
||||||
|
loadSportbetrieb()
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.saveSpielplan(headers, rows)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportSpielplanHeaders = headers,
|
||||||
|
sportSpielplanRows = rows,
|
||||||
|
message = response.message ?: "Spielplan gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveConfig(config: ConfigResponse) {
|
fun saveConfig(config: ConfigResponse) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
@@ -207,7 +409,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
|
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
ids.forEach { id ->
|
ids.forEach { id ->
|
||||||
@@ -229,7 +431,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
|
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
ids.forEach { id ->
|
ids.forEach { id ->
|
||||||
@@ -251,7 +453,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkDelete(ids: List<Int>) {
|
fun bulkDelete(ids: List<String>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
ids.forEach { id ->
|
ids.forEach { id ->
|
||||||
@@ -262,7 +464,7 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteNews(id: Int) {
|
fun deleteNews(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
repository.deleteNews(id)
|
repository.deleteNews(id)
|
||||||
@@ -451,3 +653,18 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun SpielDto.valueForHeader(header: String): String = when (header) {
|
||||||
|
"Termin" -> termin
|
||||||
|
"HeimMannschaft" -> heimMannschaft
|
||||||
|
"GastMannschaft" -> gastMannschaft
|
||||||
|
"HeimMannschaftAltersklasse" -> heimAltersklasse
|
||||||
|
"GastMannschaftAltersklasse" -> gastAltersklasse
|
||||||
|
"Altersklasse" -> altersklasse
|
||||||
|
"Liga" -> liga
|
||||||
|
"Staffel" -> staffel
|
||||||
|
"Runde" -> runde.orEmpty()
|
||||||
|
"SpieleHeim" -> spieleHeim
|
||||||
|
"SpieleGast" -> spieleGast
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt
Normal file → Executable file
4
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Normal file → Executable file
4
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Normal file → Executable file
@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import de.harheimertc.R
|
import de.harheimertc.R
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.ImageGrid
|
import de.harheimertc.ui.components.ImageGrid
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
CircularProgressIndicator()
|
LoadingState("Galerie wird geladen...")
|
||||||
} else if (images.isEmpty()) {
|
} else if (images.isEmpty()) {
|
||||||
Text(text = stringResource(R.string.gallery_empty))
|
Text(text = stringResource(R.string.gallery_empty))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt
Normal file → Executable file
105
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt
Normal file → Executable file
105
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt
Normal file → Executable file
@@ -44,15 +44,14 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import de.harheimertc.BuildConfig
|
|
||||||
import de.harheimertc.data.HomepageSectionDto
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.SeasonDto
|
import de.harheimertc.data.SeasonDto
|
||||||
@@ -77,9 +76,9 @@ import java.util.Locale
|
|||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
showNavigationHeader: Boolean = true,
|
showNavigationHeader: Boolean = true,
|
||||||
viewModel: HomeViewModel = hiltViewModel(),
|
navigationViewModel: NavigationViewModel,
|
||||||
|
viewModel: HomeViewModel,
|
||||||
) {
|
) {
|
||||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
|
||||||
val navigationState by navigationViewModel.state.collectAsState()
|
val navigationState by navigationViewModel.state.collectAsState()
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||||
@@ -107,30 +106,82 @@ fun HomeScreen(
|
|||||||
AppNavigationHeader(
|
AppNavigationHeader(
|
||||||
selectedRoute = Destinations.Home.route,
|
selectedRoute = Destinations.Home.route,
|
||||||
onNavigate = navController::navigate,
|
onNavigate = navController::navigate,
|
||||||
|
onLogout = {
|
||||||
|
navigationViewModel.logout {
|
||||||
|
navController.navigate(Destinations.Home.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationState = navigationState,
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
if (navigationState.canAccessFullCms) {
|
||||||
HomeCustomizationSection(
|
item {
|
||||||
sections = state.homepageSections,
|
HomeCustomizationSection(
|
||||||
spielplanSeasons = state.spielplanSeasons,
|
sections = state.homepageSections,
|
||||||
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
spielplanSeasons = state.spielplanSeasons,
|
||||||
editEnabled = editHomeSections,
|
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
||||||
onToggleEdit = { editHomeSections = !editHomeSections },
|
editEnabled = editHomeSections,
|
||||||
onMoveUp = viewModel::moveSectionUp,
|
onToggleEdit = { editHomeSections = !editHomeSections },
|
||||||
onMoveDown = viewModel::moveSectionDown,
|
onMoveUp = viewModel::moveSectionUp,
|
||||||
onEnabledChange = viewModel::setSectionEnabled,
|
onMoveDown = viewModel::moveSectionDown,
|
||||||
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
onEnabledChange = viewModel::setSectionEnabled,
|
||||||
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
||||||
onReset = viewModel::resetSections,
|
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 ->
|
state.homepageSections.forEachIndexed { index, section ->
|
||||||
if (!section.enabled) return@forEachIndexed
|
if (!section.enabled) return@forEachIndexed
|
||||||
val sectionKey = homeSectionKey(section)
|
val sectionKey = homeSectionKey(section)
|
||||||
when (section.id) {
|
when (section.id) {
|
||||||
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() }
|
"banner" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
|
WebHero(imageUrl = state.heroImageUrl)
|
||||||
|
}
|
||||||
"termine" -> item(key = "home_section_${sectionKey}_$index") {
|
"termine" -> item(key = "home_section_${sectionKey}_$index") {
|
||||||
HomeTermineSection(
|
HomeTermineSection(
|
||||||
termine = state.termine,
|
termine = state.termine,
|
||||||
@@ -454,7 +505,7 @@ private fun HomeSpielplanTeamWidgetSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WebHero() {
|
private fun WebHero(imageUrl: String?) {
|
||||||
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
|
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -463,12 +514,14 @@ private fun WebHero() {
|
|||||||
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
|
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
if (!imageUrl.isNullOrBlank()) {
|
||||||
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
|
AsyncImage(
|
||||||
contentDescription = null,
|
model = imageUrl,
|
||||||
modifier = Modifier.matchParentSize().alpha(0.10f),
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
modifier = Modifier.matchParentSize().alpha(0.10f),
|
||||||
)
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
|||||||
23
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt
Normal file → Executable file
23
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt
Normal file → Executable file
@@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption(
|
|||||||
|
|
||||||
data class HomeUiState(
|
data class HomeUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
|
val heroImageUrl: String? = null,
|
||||||
val termine: List<TerminDto> = emptyList(),
|
val termine: List<TerminDto> = emptyList(),
|
||||||
val spiele: List<SpielDto> = emptyList(),
|
val spiele: List<SpielDto> = emptyList(),
|
||||||
val news: List<NewsDto> = emptyList(),
|
val news: List<NewsDto> = emptyList(),
|
||||||
@@ -44,6 +45,8 @@ data class HomeUiState(
|
|||||||
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
|
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
|
||||||
val widgetsLoading: Boolean = false,
|
val widgetsLoading: Boolean = false,
|
||||||
val error: Boolean = false,
|
val error: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val debugDiagnostics: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -62,7 +65,12 @@ class HomeViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(loading = true, error = false)
|
_state.value = _state.value.copy(
|
||||||
|
loading = true,
|
||||||
|
error = false,
|
||||||
|
errorMessage = null,
|
||||||
|
debugDiagnostics = emptyList(),
|
||||||
|
)
|
||||||
repository.fetchHomeData()
|
repository.fetchHomeData()
|
||||||
.onSuccess { data ->
|
.onSuccess { data ->
|
||||||
serverSections = normalizedHomepageSections(data.homepageSections)
|
serverSections = normalizedHomepageSections(data.homepageSections)
|
||||||
@@ -80,8 +88,9 @@ class HomeViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
_state.value = HomeUiState(
|
_state.value = HomeUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
|
heroImageUrl = data.heroImageUrl,
|
||||||
termine = data.termine
|
termine = data.termine
|
||||||
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
|
.filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
|
||||||
.sortedBy { it.asDateTime() }
|
.sortedBy { it.asDateTime() }
|
||||||
.take(3),
|
.take(3),
|
||||||
spiele = data.spiele
|
spiele = data.spiele
|
||||||
@@ -99,10 +108,16 @@ class HomeViewModel @Inject constructor(
|
|||||||
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||||
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||||
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||||
|
debugDiagnostics = data.diagnostics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure { err ->
|
||||||
_state.value = HomeUiState(loading = false, error = true)
|
_state.value = HomeUiState(
|
||||||
|
loading = false,
|
||||||
|
error = true,
|
||||||
|
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
|
||||||
|
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt
Normal file → Executable file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt
Normal file → Executable file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user