Compare commits
341 Commits
backup/rem
...
552068da89
| Author | SHA1 | Date | |
|---|---|---|---|
| 552068da89 | |||
|
|
c550fe8581 | ||
|
|
472bec4519 | ||
|
|
10a37180de | ||
|
|
4ecc2cc90c | ||
|
|
7a991a50c7 | ||
| d26737e38c | |||
|
|
bf74d41773 | ||
|
|
6369249f40 | ||
|
|
6b9198ac41 | ||
| 032b1a531c | |||
|
|
3dc293517f | ||
|
|
b86555744f | ||
|
|
5c964592a8 | ||
| 3b9d8a1623 | |||
|
|
91335422aa | ||
|
|
87e08df93a | ||
| 8d7f7d7d66 | |||
|
|
835a31a72e | ||
|
|
9eacbc77ea | ||
| 8fe3c56a9e | |||
|
|
1fc28a6438 | ||
|
|
d3e6fbf49b | ||
|
|
a9ce55699e | ||
|
|
3996e8d68c | ||
|
|
1a7ba583d8 | ||
|
|
645e4b9655 | ||
|
|
93b76d4446 | ||
|
|
2842c89bef | ||
|
|
dc08ae98ea | ||
| 7ac2fd39da | |||
|
|
5e8006323b | ||
|
|
e02de6cbb2 | ||
|
|
3e981de770 | ||
| 4705d411d5 | |||
|
|
65e7563e79 | ||
|
|
9bc9501219 | ||
| 64bd7d56e5 | |||
|
|
5ca4253b44 | ||
|
|
eb75f86d45 | ||
|
|
395e2a84bd | ||
|
|
3fb60227df | ||
|
|
9a4eb77c56 | ||
|
|
02c1765214 | ||
|
|
ee93e15af8 | ||
|
|
d31026a606 | ||
|
|
4dca2af645 | ||
|
|
63b5786e43 | ||
|
|
497d804244 | ||
|
|
9ca1464899 | ||
|
|
8f6fda2e61 | ||
| 446ec752b4 | |||
|
|
3314aa06b4 | ||
| aa3d065294 | |||
|
|
12c909d6a4 | ||
|
|
c2594fd330 | ||
|
|
00b08e2f92 | ||
|
|
93da9fbcbe | ||
|
|
82dcb1da40 | ||
| ffc4b66601 | |||
|
|
e82c1d9b0a | ||
|
|
c1c648de74 | ||
|
|
7523023be8 | ||
|
|
2103e9c1e8 | ||
| f1fc5eafc3 | |||
|
|
7aadb5e215 | ||
|
|
76d7000735 | ||
|
|
51e59e85ef | ||
|
|
0475e3084d | ||
|
|
9889430109 | ||
|
|
cb89fdd911 | ||
| 42398dad21 | |||
|
|
281c25b05d | ||
|
|
1beb5c2eee | ||
|
|
461f2de8dc | ||
|
|
75a7c409cd | ||
|
|
751c302036 | ||
|
|
f0e1ad39b0 | ||
|
|
1dc84023d0 | ||
|
|
30465c7833 | ||
|
|
d260f00756 | ||
|
|
f0ac4b7e56 | ||
|
|
bd45beadd5 | ||
|
|
f060c9771f | ||
| d563d81584 | |||
|
|
0ab71166aa | ||
|
|
d9c5d242ec | ||
| 380d0a8332 | |||
|
|
cb1e7a2fed | ||
|
|
3586704cce | ||
| 301bd7acbd | |||
|
|
7f3b58c5bc | ||
|
|
f29e8a4dac | ||
| 05e25aa3d1 | |||
|
|
b69130c2b2 | ||
|
|
77aabef4a9 | ||
|
|
4b699de853 | ||
|
|
e3cb7282bc | ||
|
|
e537839e28 | ||
|
|
44d441811c | ||
|
|
da1efa5a74 | ||
| ecae88af78 | |||
| c7a306e8fa | |||
|
|
14cd5f04d5 | ||
|
|
7e533fae49 | ||
|
|
8393f154e5 | ||
|
|
c956869e8a | ||
|
|
4eabb3b766 | ||
|
|
146dedd9b4 | ||
|
|
6076194497 | ||
|
|
9cde1ab78b | ||
|
|
f5facaa811 | ||
|
|
78015298ec | ||
|
|
b4e1c50ea3 | ||
|
|
5da11d2e4d | ||
|
|
e8a50e55ca | ||
|
|
530e544542 | ||
|
|
300dce9835 | ||
|
|
a98def915e | ||
|
|
7aa7970f2e | ||
|
|
e517720b03 | ||
|
|
402913d877 | ||
|
|
2014abe660 | ||
|
|
80834d8652 | ||
| 45de2a576c | |||
|
|
7bc98c03e4 | ||
|
|
bf1caefde4 | ||
|
|
6983186caf | ||
| e1ad5f7205 | |||
|
|
7c93966878 | ||
| 14341b7a63 | |||
|
|
31d20f1bff | ||
|
|
6507afea5f | ||
|
|
387ce6e08e | ||
|
|
f822fc8a8e | ||
|
|
67c746f18b | ||
| 803481ca8e | |||
|
|
1e65cb47da | ||
|
|
ec96e21517 | ||
|
|
46f80df165 | ||
|
|
5c3d78245f | ||
|
|
f5045c3cf0 | ||
|
|
696c50f0fc | ||
|
|
b8bdbf0a8d | ||
|
|
1ea9596006 | ||
|
|
cdbe71eaec | ||
|
|
125a00819d | ||
|
|
b4c31374c0 | ||
|
|
c8b7f5ec2e | ||
| 46a8d5a77a | |||
|
|
0528334eb4 | ||
|
|
e033d716dd | ||
| 96c1d0260b | |||
|
|
e195d5d189 | ||
| 6e00a1b829 | |||
|
|
533e89c405 | ||
|
|
083244bc83 | ||
|
|
9def0fdc32 | ||
|
|
512756cb48 | ||
|
|
19d2f21fc3 | ||
|
|
5074e8f8f8 | ||
|
|
18a08b0e7a | ||
|
|
026e4ba3e4 | ||
|
|
58fd7fa5c6 | ||
| 755442fb70 | |||
| d31515bc79 | |||
|
|
1710c9349d | ||
|
|
cd025b1f92 | ||
|
|
2c681cf65c | ||
|
|
92099685e6 | ||
|
|
7e0c92368e | ||
|
|
817f5e02ca | ||
|
|
d3be0a269f | ||
|
|
8e318b0b52 | ||
| b729e90e38 | |||
|
|
acfcf773f7 | ||
|
|
1ea7f7a63f | ||
|
|
7289adb7a0 | ||
|
|
19d7aeefb0 | ||
| 92c0610dcb | |||
|
|
5fce08ab75 | ||
|
|
1c9dff0932 | ||
|
|
fd83b18642 | ||
| 6db05c1dc6 | |||
|
|
84536956c4 | ||
|
|
459da00820 | ||
|
|
f883d45452 | ||
|
|
11ff823fe2 | ||
|
|
cfd9365d07 | ||
|
|
f2f76dec56 | ||
|
|
2d42ef3ecd | ||
|
|
e19158558d | ||
|
|
bf4db389ff | ||
|
|
964a68cdfd | ||
|
|
21b39d4e5c | ||
| 68b6ab593a | |||
|
|
3658589d94 | ||
| 4e42ddfee4 | |||
|
|
a80ea7b892 | ||
| 6a6b8e0a1b | |||
|
|
f7c6caebc1 | ||
| ddb170cffc | |||
|
|
79e2ae9b87 | ||
| 66f2718714 | |||
|
|
4891e965bb | ||
| f2f5ddb8ce | |||
|
|
2fab6be58a | ||
|
|
549f4a1510 | ||
| 4d13ea5de6 | |||
|
|
02ee4af49d | ||
|
|
cc253c24db | ||
| 45d343b1c1 | |||
|
|
2eeed60387 | ||
|
|
63a1034e3d | ||
| 7b9bdb9a36 | |||
|
|
1561e1b861 | ||
|
|
3f7149d622 | ||
|
|
bc9cc265e1 | ||
| a48c65bfb8 | |||
|
|
fdf72bdb96 | ||
|
|
dbcc55d7fc | ||
|
|
f8a0370910 | ||
| 9a59fa5525 | |||
|
|
41374da6ea | ||
| 70acfe6d5e | |||
|
|
e5c247f703 | ||
| 01f7d075e9 | |||
|
|
2503eb92af | ||
| bb2785ba56 | |||
|
|
4e918870f5 | ||
| e871790ee8 | |||
|
|
0849c625cb | ||
| b19a9f6b8e | |||
|
|
c78adc0d52 | ||
|
|
806a4be2f9 | ||
|
|
5f96f719c1 | ||
|
|
28b1001826 | ||
|
|
dcc4055eca | ||
|
|
4521ce002e | ||
|
|
842a516ce6 | ||
|
|
48f8b46e57 | ||
|
|
8ae7dcdbf1 | ||
| 667b65bf29 | |||
|
|
dba2747883 | ||
| 21752fedcc | |||
|
|
a30692a053 | ||
| 742687f82b | |||
|
|
fddde56076 | ||
|
|
c385df4a0c | ||
| 7152b54b68 | |||
|
|
e44d3c5c74 | ||
|
|
c409fa6d4b | ||
| 21b4e8bc9f | |||
|
|
0fa19493c5 | ||
|
|
c145a723ed | ||
| 91fb3d79c5 | |||
|
|
d0b15f3e83 | ||
|
|
e60c0f4481 | ||
|
|
27a096546f | ||
| aee8705fa3 | |||
|
|
20a1cdd7f2 | ||
|
|
e3825ad217 | ||
| 99c03dccf2 | |||
|
|
a12f1f7815 | ||
| d450175871 | |||
|
|
6fea2749e0 | ||
|
|
18da725567 | ||
|
|
4d5fb43ebc | ||
|
|
986b2056cd | ||
|
|
337c172d07 | ||
|
|
15b8f3c4c1 | ||
|
|
510cfd39f9 | ||
|
|
e0bad51764 | ||
|
|
c1de0c1671 | ||
|
|
2bedbee08d | ||
|
|
9c54b6907e | ||
|
|
edfab28fd3 | ||
|
|
5f79d220cf | ||
|
|
0a82b33afc | ||
|
|
1922e85184 | ||
|
|
0fb8052a77 | ||
|
|
ef2d9353f5 | ||
|
|
1aae808e5f | ||
|
|
75e6d66d25 | ||
|
|
daabeec33c | ||
|
|
0fb58af194 | ||
|
|
8ffd267dfc | ||
|
|
5eee7df7e4 | ||
|
|
7dea265eef | ||
|
|
381ec55fd1 | ||
|
|
c30911daed | ||
|
|
bdc9eef707 | ||
|
|
f7701d698f | ||
|
|
49e7255062 | ||
|
|
74246e6b08 | ||
|
|
6230c96bc9 | ||
|
|
3fb40bd87d | ||
|
|
46c2c14ae8 | ||
|
|
ff8c1970df | ||
|
|
8347a86727 | ||
|
|
9a6d32dcb3 | ||
|
|
161618f6fb | ||
|
|
0b3fba44a4 | ||
|
|
d35e1c9a3e | ||
|
|
528353132a | ||
|
|
cd5e5cd781 | ||
|
|
ebbffcc5c4 | ||
|
|
5c760d7fa8 | ||
|
|
d40073ac7b | ||
|
|
b25cf13d3c | ||
|
|
3287102761 | ||
|
|
08624cabbe | ||
|
|
d37f182928 | ||
|
|
79c45be7c7 | ||
|
|
d52f3ffc8d | ||
|
|
64baaf8535 | ||
|
|
e665495003 | ||
|
|
8f444c59eb | ||
|
|
8117335af9 | ||
|
|
85ec99b08c | ||
|
|
04571e6444 | ||
|
|
5799f97570 | ||
|
|
8ab08f4c09 | ||
|
|
fcf3168692 | ||
|
|
cfd209d7ee | ||
|
|
ee1709ffb2 | ||
|
|
8bb02b6e4a | ||
|
|
7a20af2772 | ||
|
|
3e610e68b6 | ||
|
|
485b21c13e | ||
|
|
08b1edc354 | ||
|
|
6e297c682c | ||
|
|
3d3e22bb1b | ||
|
|
d18b671532 | ||
|
|
af3c0164ef | ||
|
|
c681194462 | ||
|
|
141a15a6cb | ||
|
|
ce5915a3bc | ||
|
|
677140bd33 | ||
|
|
8a1e309eba | ||
|
|
0d533710cd |
125
.gitea/workflows/code-analysis.yml
Normal file → Executable file
125
.gitea/workflows/code-analysis.yml
Normal file → Executable file
@@ -1,16 +1,47 @@
|
||||
name: Code Analysis (JS/Vue)
|
||||
name: Code Analysis and Production Deploy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, dev ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
clean: true
|
||||
# Der Analyse-Workflow benötigt keine Historie. Ein flacher Checkout
|
||||
# verhindert, dass der Runner das große Repository-Pack vollständig lädt.
|
||||
fetch-depth: 1
|
||||
# Der Web-Workflow braucht keine Android-Artefakte. Insbesondere liegt
|
||||
# dort ein großer Heap-Dump im aktuellen Commit, der auch bei einem
|
||||
# flachen Checkout übertragen würde und den Runner-Checkout abbricht.
|
||||
sparse-checkout: |
|
||||
/*
|
||||
!/android-app/
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Ensure clean workspace
|
||||
run: |
|
||||
git reset --hard HEAD
|
||||
git clean -fdx
|
||||
|
||||
- name: Debug dependency files
|
||||
run: |
|
||||
echo "commit: $(git rev-parse HEAD)"
|
||||
echo "branch ref: ${GITHUB_REF:-unknown}"
|
||||
echo "package.json checksum:" && sha256sum package.json
|
||||
echo "package-lock.json checksum:" && sha256sum package-lock.json
|
||||
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Workspace sanity check
|
||||
run: |
|
||||
@@ -25,6 +56,15 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Check package.json version vs production
|
||||
if: "github.ref == 'refs/heads/main'"
|
||||
env:
|
||||
PROD_HOST: ${{ vars.PROD_HOST }}
|
||||
PROD_USER: ${{ vars.PROD_USER }}
|
||||
PROD_PORT: ${{ vars.PROD_PORT }}
|
||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
run: bash scripts/check-version-against-prod.sh
|
||||
|
||||
- name: gitleaks (Secrets Scanning)
|
||||
run: |
|
||||
# Try to get the latest release asset URL
|
||||
@@ -58,7 +98,11 @@ jobs:
|
||||
rm -f gitleaks.tar.gz
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: |
|
||||
if ! npm ci; then
|
||||
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
|
||||
npm install
|
||||
fi
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -72,13 +116,76 @@ jobs:
|
||||
- name: Semgrep (SAST)
|
||||
run: semgrep --config p/default --error .
|
||||
|
||||
- name: npm audit (high+)
|
||||
run: npm audit --audit-level=high
|
||||
- name: npm audit (critical)
|
||||
run: npm audit --audit-level=critical
|
||||
|
||||
- name: OSV-Scanner (SCA)
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
||||
chmod +x osv-scanner
|
||||
./osv-scanner --version
|
||||
test -f ./package-lock.json
|
||||
./osv-scanner --lockfile ./package-lock.json
|
||||
test -f "$GITHUB_WORKSPACE/package-lock.json"
|
||||
./osv-scanner scan -L "$GITHUB_WORKSPACE/package-lock.json" --config "$GITHUB_WORKSPACE/.osv-scanner.toml" --format json --output-file osv-report.json || true
|
||||
node scripts/check-osv-severity.mjs osv-report.json
|
||||
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"echo SSH OK"
|
||||
|
||||
- name: Run production deployment script
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||
|
||||
deploy-test:
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"echo SSH OK"
|
||||
|
||||
- name: Run test deployment script
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||
|
||||
11
.gitea/workflows/version-gate.yml
Executable file
11
.gitea/workflows/version-gate.yml
Executable file
@@ -0,0 +1,11 @@
|
||||
name: Require Package Version Change (disabled)
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Disabled
|
||||
run: echo "version-gate workflow disabled — gating is handled in code-analysis.yml"
|
||||
28
.github/workflows/android-ci.yml
vendored
Executable file
28
.github/workflows/android-ci.yml
vendored
Executable file
@@ -0,0 +1,28 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper/
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
- name: Build
|
||||
run: |
|
||||
./gradlew :app:assembleDebug
|
||||
17
.gitignore
vendored
Normal file → Executable file
17
.gitignore
vendored
Normal file → Executable file
@@ -88,6 +88,17 @@ out
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Android / Gradle generated and machine-local files
|
||||
/android-app/.gradle/
|
||||
/android-app/.kotlin/
|
||||
/android-app/**/build/
|
||||
/android-app/local.properties
|
||||
/android-app/gradle-local.properties
|
||||
# Android Play Store / release artifacts are generated locally or in CI.
|
||||
/android-app/**/*.aab
|
||||
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
|
||||
*.hprof
|
||||
|
||||
# Build output (but keep production data!)
|
||||
.output
|
||||
!.output/.gitkeep
|
||||
@@ -143,6 +154,8 @@ Thumbs.db
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
temp/webpage-downloads/data/
|
||||
temp/webpage-downloads/*.html
|
||||
|
||||
# Security tooling artifacts (CI downloads)
|
||||
gitleaks
|
||||
@@ -154,3 +167,7 @@ server/data/**
|
||||
!server/data/.gitkeep
|
||||
public/data/**
|
||||
public/uploads/**
|
||||
backups/*
|
||||
public/data
|
||||
server/data
|
||||
public/uploads
|
||||
|
||||
7
.gitleaks.toml
Executable file
7
.gitleaks.toml
Executable file
@@ -0,0 +1,7 @@
|
||||
[[allowlists]]
|
||||
description = "generated/imported non-secret data"
|
||||
paths = [
|
||||
'''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''',
|
||||
'''android-app/app/build/.*''',
|
||||
'''android-app/\.idea/planningMode\.xml$''',
|
||||
]
|
||||
3
.gitleaksignore
Executable file
3
.gitleaksignore
Executable file
@@ -0,0 +1,3 @@
|
||||
google-services.json:gcp-api-key:18
|
||||
google-services.json:gcp-api-key:37
|
||||
google-services.json:gcp-api-key:56
|
||||
2
.osv-scanner.toml
Normal file
2
.osv-scanner.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
# Retained because the CI scanner is invoked with --config. Findings are
|
||||
# evaluated transparently by scripts/check-osv-severity.mjs.
|
||||
0
.semgrepignore
Normal file → Executable file
0
.semgrepignore
Normal file → Executable file
36
ANDROID_ARCHITECTURE.md
Executable file
36
ANDROID_ARCHITECTURE.md
Executable file
@@ -0,0 +1,36 @@
|
||||
Android Architektur (Kotlin + Jetpack Compose) — Vorschlag
|
||||
|
||||
Packages/Module Struktur:
|
||||
- app/ (Android-App module)
|
||||
- src/main/java/de/harheimertc/
|
||||
- ui/
|
||||
- navigation/ (NavGraph, Routes)
|
||||
- screens/ (HomeScreen, TermineScreen, SpielplanScreen, GalleryScreen, ContactScreen, AuthScreens, CMS Screens)
|
||||
- components/ (TopBar, BottomNav, Cards, Modals)
|
||||
- theme/ (Color.kt, Typography.kt, Theme.kt)
|
||||
- data/
|
||||
- api/ (Retrofit interfaces, DTOs)
|
||||
- repository/ (Repositories für Domain-Modelle)
|
||||
- local/ (Room DAOs, Entities)
|
||||
- di/ (Hilt Modules)
|
||||
- domain/ (UseCases, Business-Logic)
|
||||
- util/ (Extensions, DateUtils, ImageUtils)
|
||||
- auth/ (AuthManager, Passkeys helper)
|
||||
|
||||
Wichtige Dateien:
|
||||
- `MainActivity.kt` — Hosts Compose NavHost
|
||||
- `AppTheme.kt` — Compose Material3 Theme mit Token-Mapping
|
||||
- `NetworkModule` (Hilt) — Retrofit + OkHttp + Auth Interceptor
|
||||
- `Repository` Layer — entkoppelt UI von Netz
|
||||
- `Room` Entities — für Caching von Termine/News/Galerie
|
||||
|
||||
Auth-Strategie:
|
||||
- AuthRepository verwaltet Login/Logout, `checkAuth()` (mirroring `/api/auth/status`).
|
||||
- Token/Cookie-Speicherung: `EncryptedSharedPreferences` für Tokens oder `CookieJar` mit OKHttp-Client.
|
||||
- Passkeys: `Fido2Client` wrapper + Bridge zu Server-API (Formate prüfen).
|
||||
|
||||
Build / Module Tipps:
|
||||
- Start mit Single Module `app/` und später evtl. `:data`, `:domain` Trennung.
|
||||
- Verwende Gradle Kotlin DSL (build.gradle.kts).
|
||||
|
||||
Diese Architekturdatei wurde generiert; ich kann nun ein initiales Gradle-Kotlin-Scaffold erzeugen. Soll ich das direkt in `android-app/` ablegen? (Ja/Nein)
|
||||
372
ANDROID_KOTLIN_PLAN.md
Executable file
372
ANDROID_KOTLIN_PLAN.md
Executable file
@@ -0,0 +1,372 @@
|
||||
Android App — Kotlin (Jetpack Compose) Plan und Abhakliste
|
||||
|
||||
Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web-UI 1:1 abbildet (Farben, Typografie, Funktionalitäten). Diese Datei enthält Architekturentscheidungen, empfohlene Bibliotheken und eine detaillierte Abhakliste (schrittweise).
|
||||
|
||||
1) Zusammenfassung der Entscheidungen
|
||||
- Plattform: Native Android
|
||||
- Sprache: Kotlin
|
||||
- UI-Toolkit: Jetpack Compose (Compose Material3)
|
||||
- Architektur: MVVM mit `ViewModel` + Kotlin Coroutines + Flow
|
||||
- DI: Hilt
|
||||
- HTTP-Client: Ktor Client oder Retrofit + OkHttp (empfohlen: Retrofit für breite Community-Docs)
|
||||
- Bild-Loading: Coil
|
||||
- Lokale DB / Caching: Room + DataStore (Preferences)
|
||||
- Background/Sync: WorkManager
|
||||
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
||||
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
|
||||
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
|
||||
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
|
||||
|
||||
2) Design & Farben
|
||||
- Material Theme (Material3) mit Farben aus `tailwind.config.js` (Primary + Accent).
|
||||
- Fonts: Inter & Montserrat via Google Fonts (Download/Bundle oder Play-Services-Download at runtime).
|
||||
- Mapping: Tailwind-Token → `colors.xml` / Compose `Color` tokens.
|
||||
|
||||
3) Empfohlene Abhängigkeiten (erste Implementierung)
|
||||
- androidx.compose.* (ui, material3, navigation)
|
||||
- androidx.lifecycle:lifecycle-viewmodel-ktx
|
||||
- com.google.dagger:hilt-android
|
||||
- retrofit2 + converter-moshi / kotlinx-serialization
|
||||
- io.coil-kt:coil-compose
|
||||
- androidx.room:room-runtime + room-ktx
|
||||
- androidx.work:work-runtime-ktx
|
||||
- androidx.datastore:datastore-preferences
|
||||
- com.google.android.gms:play-services-auth (für passkeys falls nötig)
|
||||
- io.sentry:sentry-android (optional)
|
||||
|
||||
4) Detaillierte Abhakliste (Schritte)
|
||||
[x] 1. Repo-Analyse: Liste der externen Endpunkte und Auth-Anforderungen exportieren
|
||||
[x] 2. Projekt-Scaffold: Android Studio Projekt mit Kotlin + Compose anlegen
|
||||
[x] 3. App-Architektur: Module / Packages anlegen (ui, data, domain, di, util)
|
||||
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
|
||||
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
|
||||
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
|
||||
[x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
|
||||
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
|
||||
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
|
||||
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
|
||||
[x] 7d. Tablet-Navigation: öffentliche Haupt- und Subnavigation der Web-UI mit Portierungszielen abbilden
|
||||
[x] 7e. Navigation: dynamische Mannschaftslinks, Galerie-Sichtbarkeit und rollenabhängiges `Intern` wie in der Web-UI anbinden
|
||||
[x] 8. Start-Screen: `HomeScreen` webnah mit Hero, Navigation, Termine, Spielen, News und Aktionen umsetzen
|
||||
[x] 9. Komponenten: NavBar, Footer, Cards, ImageGrid und News-Dialog implementieren
|
||||
[x] 10. Öffentliche Screens aus der Web-Navigation portieren
|
||||
- [x] `/` Startseite
|
||||
- [x] `/termine`: öffentliche Terminliste mit Lade-, Leer- und Fehlerzustand
|
||||
- [x] `/mannschaften/spielplaene`: Saison-, Wettbewerbs- und Mannschaftsfilter mit Spielkarten
|
||||
- [x] `/verein/galerie`: Anzeige-Screen vorhanden
|
||||
- [x] `/kontakt`: Formular-Screen vorhanden
|
||||
- [x] `/mitgliedschaft`: Antrag, Validierung, PDF-Erzeugung und PDF-Öffnen
|
||||
- [x] `/verein/ueber-uns`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/vorstand`: öffentliche Vorstandsangaben aus der Konfiguration
|
||||
- [x] `/verein/geschichte`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/verein/satzung`: CMS-Inhalt und PDF-Aufruf aus der öffentlichen Konfiguration
|
||||
- [x] `/vereinsmeisterschaften`: Ergebnisliste mit Jahresfilter und Statistik
|
||||
- [x] `/links`: strukturierte CMS-Links mit Fallback-Verweisen
|
||||
- [x] `/mannschaften`: Übersicht aus saisonaler Mannschafts-CSV
|
||||
- [x] `/mannschaften/[slug]`: dynamische Mannschaftsdetails mit aktuellem Spielplan und Umschaltung `Matches`/`Tabelle`
|
||||
- [x] `/spielsysteme`: Spielsystemkarten mit Kategoriefilter aus CSV
|
||||
- [x] `/training`: Trainingsort und gruppierte Trainingszeiten aus der Konfiguration
|
||||
- [x] `/training/trainer`
|
||||
- [x] `/training/anfaenger`
|
||||
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
|
||||
[x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
|
||||
- [x] `/impressum`
|
||||
- [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
|
||||
[x] 10b. Newsletter-Screens portieren
|
||||
- [x] `/newsletter/subscribe`
|
||||
- [x] `/newsletter/unsubscribe`
|
||||
- [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
|
||||
[x] 10c. Auth-Screens portieren
|
||||
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
|
||||
- [x] `/registrieren`
|
||||
- [x] `/passwort-vergessen`
|
||||
[x] 10d. Mitgliederbereich portieren
|
||||
- [x] `/mitgliederbereich`: Übersicht
|
||||
- [x] `/mitgliederbereich/mitglieder`
|
||||
- [x] `/mitgliederbereich/news`
|
||||
- [x] `/mitgliederbereich/profil`
|
||||
- [x] `/mitgliederbereich/api`
|
||||
[x] 10e. CMS-Screens nach Rollenberechtigung portieren
|
||||
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
|
||||
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
|
||||
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
|
||||
[x] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
|
||||
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
|
||||
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
|
||||
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||
- [x] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-/Newsletter-/Galerie-Endpunkte erledigt); Web bleibt bewusst Cookie-basiert
|
||||
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
||||
- [x] Registrierung und Passwort-Reset
|
||||
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
|
||||
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
||||
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
||||
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||
[x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||
- [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden
|
||||
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
|
||||
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
|
||||
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
|
||||
[x] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
||||
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
||||
- [x] Nativer Rich-Text-Editor für CMS-Inhalte ergänzt; Toolbar schreibt Quill-kompatible HTML-Fragmente und speichert denselben HTML-String wie die Web-UI
|
||||
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
||||
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
||||
[x] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||
- [x] Zentraler OkHttp-Cache für öffentliche GET-Antworten ergänzt; Offline-Fallback nutzt gecachte Antworten bis 7 Tage
|
||||
- [x] Zentraler Coil-ImageLoader mit gemeinsamem OkHttp-Client, Memory-Cache und 75-MB-Diskcache ergänzt
|
||||
- [x] Verschlüsselte persistente Offline-Daten für geschützte Mitglieder-/CMS-Inhalte mit `EncryptedSharedPreferences` implementiert
|
||||
[x] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||
- [x] App-Name und neue Galerie-Upload-Texte in deutsche und englische Ressourcen ausgelagert
|
||||
- [x] i18n-Check durchgeführt; ältere Compose-Harttexte bleiben als separate, risikoarme Nachmigration offen
|
||||
[x] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
|
||||
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
|
||||
- [x] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
|
||||
- [ ] Compose-UI-Tests für kritische Screens ergänzen
|
||||
- [x] Hilt androidTest dependencies und `kspAndroidTest` konfiguriert
|
||||
- [x] `HiltTestApplication` in `androidTest`-Manifest gesetzt
|
||||
- [x] `LoginScreenTest` zu `@HiltAndroidTest` migriert und `HiltAndroidRule` hinzugefügt
|
||||
- [x] `TestHiltModules.kt` für androidTest hinzugefügt (Test‑Bindings bereitgestellt)
|
||||
[x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
||||
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
||||
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
||||
- [x] Technische Release-Basis vorbereitet: `ANDROID_VERSION_CODE` und `ANDROID_VERSION_NAME` als Gradle-Properties eingeführt (`android-app/gradle.properties`) und im App-Gradle verdrahtet.
|
||||
- [x] Production-Release-Flavor auf Produktiv-Backend parametrisierbar gemacht (`PRODUCTION_API_BASE_URL`, Default `https://harheimertc.de/`).
|
||||
- [x] Release-Signing per sicheren Gradle-Properties vorbereitet (`RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`) statt Hardcoding.
|
||||
- [x] `:app:assembleProductionRelease` erfolgreich gebaut (Stand 2026-05-29).
|
||||
- [x] Play-Store-Listing-Basis ergänzt: Datenschutzseite unter `/datenschutz` sowie Skripte für Icon/Feature-Graphic-Export und Screenshot-Anonymisierung inklusive Anleitung (`android-app/PLAYSTORE_ASSETS.md`).
|
||||
- [x] Konto-Lösch-URL für Play Store ergänzt: öffentliche Seite unter `/konto-loeschen` inklusive Prozessbeschreibung.
|
||||
- [ ] Offen: Finales Upload-Keystore + Credentials in CI/Build-Host hinterlegen, Play-Store-Release-Notes und Store-Metadaten pflegen.
|
||||
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
|
||||
|
||||
5) Kurzzeit-MVP (Priorität für erste Version)
|
||||
- [x] A. Auth (Login/Logout)
|
||||
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
||||
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
||||
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||
- [x] C. Kontaktformular (absenden)
|
||||
- [x] D. Bildanzeige + Caching
|
||||
- [x] E. Theme & Fonts
|
||||
|
||||
6) Nächste Aktionen (sofort)
|
||||
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
|
||||
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
|
||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||
- Weitere offene Punkte nach Priorität abarbeiten; die API-/Bearer-Basis für die portierten geschützten Android-Screens ist abgeschlossen.
|
||||
|
||||
7) Umsetzungsprotokoll
|
||||
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
||||
- 2026-05-27: `Termine` und `Spielplan` als native Screens umgesetzt; Spielplan unterstützt Saison, Wettbewerb, Mannschaft, Ergebnis und zweizeilige Gruppeninformation.
|
||||
- 2026-05-27: `Mitgliedschaft` mit Antrag/PDF-Abruf sowie Passwort-`Login`/`Logout` umgesetzt; offene Auth-Härtung separat ausgewiesen.
|
||||
- 2026-05-27: Tokens verschlüsselt persistiert; Session-Wiederherstellung sowie Logout per Bearer-Token in den Auth-Endpunkten ermöglicht.
|
||||
- 2026-05-27: Registrierung und Passwort-Reset an die vorhandenen Auth-Endpunkte angebunden.
|
||||
- 2026-05-27: Product-Flavors `local`, `instantTest` und `production` eingerichtet; lokale Basis-URL ist per Gradle-Parameter überschreibbar.
|
||||
- 2026-05-27: Gradle-Heap/Worker für Flavor-Builds festgelegt, nachdem paralleles D8/KSP mit dem 512-MiB-Standardheap nicht ausreichend Speicher hatte.
|
||||
- 2026-05-27: Lokales Testsetup gegen Emulator geprüft; bei IPv6-gebundenem Nuxt-Dev-Server wird die von Nuxt ausgegebene Network-URL per `LOCAL_API_BASE_URL` verwendet.
|
||||
- 2026-05-27: Adaptive Navigation umgesetzt; Tablet-Layouts ab `600dp` zeigen Header und Hauptmenue dauerhaft, Smartphone-Layouts behalten die vorhandene Navigation.
|
||||
- 2026-05-27: Platzhalterlogo in der Android-Navigation durch das vorhandene Harheimer-TC-Weblogo als skalierte lokale PNG-Ressource ersetzt.
|
||||
- 2026-05-27: Web-Navigation und `pages/` vollständig inventarisiert; Tablet-Haupt-/Subnavigation für die öffentlichen Bereiche strukturell angeglichen und alle fehlenden Screens einzeln in die Portierungsliste aufgenommen.
|
||||
- 2026-05-27: Tablet-Header auf Web-Verhalten angepasst (Bereichswechsel öffnet Startseite und Submenü) und die native ActionBar zugunsten des App-Headers entfernt.
|
||||
- 2026-05-27: Navigation mit Live-Mannschaftslinks, öffentlicher Galerie-Sichtbarkeit und rollenabhängigem `Intern` ergänzt; Mannschaftsübersicht/-detail sowie Training, Trainer und Anfänger nativ portiert.
|
||||
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
|
||||
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
||||
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
||||
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
||||
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
|
||||
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
|
||||
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
|
||||
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
|
||||
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
|
||||
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
|
||||
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
|
||||
- 2026-05-28: Rich-Text-Editor nativ umgesetzt: `/cms/inhalte` kann Über-uns, Geschichte, TT-Regeln und Satzung mit Android-Toolbar bearbeiten; gespeichert wird weiterhin HTML in `seiten.*` über `PUT /api/config`, kompatibel zur Web-/Quill-Ausgabe.
|
||||
- 2026-05-28: Punkt 14 umgesetzt: Android-Galerie nutzt den strukturierten `/api/galerie/list`-Response, lädt Bilder über Coil aus `/api/media/galerie/:id`, und Admin/Vorstand kann Bilder nativ auswählen, lokal auf JPEG/2000px/85% komprimieren und per Multipart an `/api/galerie/upload` senden.
|
||||
- 2026-05-28: Punkt 18 umgesetzt: `strings.xml` für Deutsch und Englisch ergänzt, App-Label und neue Galerie-Upload-UI auf Ressourcen umgestellt; i18n-Check weist die bestehenden älteren Compose-Harttexte als spätere Nachmigration aus.
|
||||
- 2026-05-28: Punkt 11 abgeschlossen: Android sendet Bearer-Tokens zentral per OkHttp-Interceptor; die portierten geschützten Backend-Endpunkte akzeptieren Cookie- oder Bearer-Authentifizierung. Die Web-UI bleibt absichtlich bei HttpOnly-Cookie-Sessions und muss nicht auf Bearer umgestellt werden.
|
||||
- 2026-05-28: Caching-Teil von Punkt 17 und MVP-D umgesetzt: OkHttp cached öffentliche GET-Antworten und nutzt gecachte Antworten offline, Coil nutzt denselben authentifizierten Client plus Memory-/Diskcache. Geschützte Daten werden bewusst nicht unverschlüsselt im HTTP-Diskcache persistiert.
|
||||
- 2026-05-28: Testbasis für Punkt 20 begonnen: JVM-Unit-Tests für E-Mail- und ISO-Datum-Validierung ergänzt; `:app:testLocalDebugUnitTest` läuft mit `compileSdk 35` grün.
|
||||
- 2026-05-28: Punkte 17, 19, 21 und 23 weiter umgesetzt: geschützte Mitglieder-/CMS-Daten werden verschlüsselt in Keystore-gestützten Preferences gecacht und bei Ladefehlern genutzt; Galerie-Accessibility und Thumbnail-Decoding verbessert; Sentry-Android 8.42.0 über optionalen `SENTRY_DSN`-Gradle-Parameter integriert.
|
||||
- 2026-05-29: Play-Store-Listing-Vorbereitung ergänzt: eigenständige Web-Datenschutzseite (`/datenschutz`) sowie Asset-/Anonymisierungs-Skripte und Anleitung in `android-app/PLAYSTORE_ASSETS.md` hinzugefügt.
|
||||
|
||||
8) Android-Testumgebungen
|
||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||
- Lokal, wenn `10.0.2.2` nicht erreichbar ist: `./gradlew :app:installLocalDebug -PLOCAL_API_BASE_URL=http://<NUXT-NETWORK-HOST>:3100/`; die passende URL steht in der `npm run dev`-Ausgabe (hier `http://torstens:3100/`).
|
||||
- Test-Instanz: `./gradlew :app:installInstantTestDebug` verwendet `https://harheimertc.tsschulz.de/` und die App-ID `de.harheimertc.test`.
|
||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||
|
||||
8a) Aktueller Teststatus & Troubleshooting (Stand: 2026-05-28)
|
||||
|
||||
- **Status:** `:app:assembleAndroidTest` läuft durch; `:app:connectedAndroidTest` ist derzeit instabil und schlägt bei Instrumentation-Läufen fehl.
|
||||
- **Beobachtete Probleme:**
|
||||
- Kompilationsfehler in `LoginScreenTest.kt` wegen `HiltTestActivity` (Unresolved reference). Workaround: `createAndroidComposeRule<ComponentActivity>()` + `setContent{}` verwenden, damit `assembleAndroidTest` durchläuft.
|
||||
- Laufzeit-/Device-Probleme bei `connectedAndroidTest`: `com.android.ddmlib.SyncException: Remote object doesn't exist!` und `DELETE_FAILED_INTERNAL_ERROR` beim Deinstallieren von Test-APKs.
|
||||
- `AndroidTestLogcatPlugin` wirft `FileNotFoundException` für erwartete Log-/Crash-Dateien, weil Gradle/UTP manche Device-Artefakte nicht zuverlässig pulled.
|
||||
- Einzelne Instrumentation-Tests (z. B. `CmsActivateResendTest`, `GalleryScreenTest`) zeigen Assertion-Fehlschläge — diese sollten isoliert reproduziert werden.
|
||||
- **Kurzfristige Empfehlungen (nicht ausführen):**
|
||||
- Emulator neu starten und sicherstellen, dass keine veralteten Test-APKs installiert sind.
|
||||
- Manuell: `adb uninstall` der Test-Pakete, dann frisches `adb install -r` des Test-APKs und gezielter Einzeltest via:
|
||||
|
||||
`adb shell am instrument -w -e class <test-class>#<testMethod> de.harheimertc.test/androidx.test.runner.AndroidJUnitRunner`
|
||||
|
||||
parallel `adb logcat -v time > /tmp/harheimertc_live_logcat.txt` laufen lassen, um vollständige Logs zu speichern.
|
||||
- Falls UTP/ddmlib `SyncException` weiter auftritt: Gradle-Parallelität reduzieren, Test-Plugins (z. B. `AndroidTestLogcatPlugin`) temporär deaktivieren oder Tests in kleinere Gruppen splitten.
|
||||
- **Offene Test‑To‑Dos:**
|
||||
- Reproduzierbaren Einzeltest-Run mit vollständigem `logcat` erfassen (derzeit vom Nutzer pausiert).
|
||||
- Flaky Tests isolieren und Hilt/KSP-Setup prüfen, damit `HiltTestActivity`-Importe nicht mehr fehlschlagen.
|
||||
- Langfristig: Tests aufteilen, flaky tests markieren und CI-Job für androidTests gegen UTP-Transient-Fehler härten.
|
||||
|
||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
|
||||
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||
- Token-Modell:
|
||||
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
||||
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
|
||||
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
|
||||
- Backend-Arbeitspaket:
|
||||
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
|
||||
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
|
||||
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
|
||||
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
|
||||
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
|
||||
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
|
||||
- Android-Arbeitspaket:
|
||||
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
|
||||
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
|
||||
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
|
||||
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
|
||||
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
|
||||
- Sicherheitsregeln:
|
||||
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
|
||||
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
|
||||
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
|
||||
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
|
||||
|
||||
---
|
||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||
|
||||
**CMS-Verbesserungsplan (Analyse → Umsetzung)**
|
||||
|
||||
Ziel: Alle `cms/*`-Screens von rudimentärem Status zu vollständigen, getesteten Admin-Tools weiterentwickeln. Fokus: Datenintegrität, Berechtigungen, bessere UI/UX, Offline-Verhalten und Tests.
|
||||
|
||||
Kurzüberblick (3 Phasen):
|
||||
- Phase A — Analyse (1-2 Tage): Inventar aller CMS-Endpunkte, fehlende CRUD-Workflows identifizieren, Prioritäten setzen (News, Benutzer, Kontaktanfragen, Newsletter, Config). Ergebnis: Aufgabenliste mit Aufwandsschätzung.
|
||||
- Phase B — Implementierung MVP (1-2 Wochen): Kernfunktionen pro Bereich implementieren (News CRUD mit RichText-Vorschau, Benutzerliste + Rollen-Edit, Kontaktanfragen Detail & Antwort-Workflow, Newsletter-Gruppen-Management, Config-Editor inklusive Satzung-PDF-Feld). Unit- / Integrationstests für ViewModels.
|
||||
- Phase C — Harden, UX & Tests (1 Woche): Validierung, Fehlermeldungen, Offline-Caching (verschlüsselt für geschützte Daten), Compose-UI-Tests, Accessibility-, Performance-Feinschliff.
|
||||
|
||||
Detaillierte Aufgaben (priorisiert):
|
||||
- A1: Audit `CmsViewModel`-State vs. Backend-Responses — fehlen Felder/Fehlerfälle? (bereits teilweise umgesetzt)
|
||||
- A2: Prüfen, ob API-Fehler (4xx/5xx) sauber an `FormMessages`/UI gemeldet werden — Standardisiere Fehlermeldungen.
|
||||
- A3: Prüfen, ob `NativeRichTextEditor` HTML speichert, das Web-Editor-kompatibel bleibt (Quill/HTML). Schreibe Roundtrip-Tests.
|
||||
- B1: News-Management
|
||||
- B1.1: News-CRUD: Create/Update/Delete mit Vorschau (RichText-Preview) und Validierung (Titel Pflicht, Inhalt Mindestlänge)
|
||||
- B1.2: Bulk-Aktionen: Sichtbar/Unsichtbar/ExpiresAt setzen
|
||||
- B1.3: Unit-Tests für `NewsViewModel` + `CmsViewModel`-Integrationspfad
|
||||
- B2: Benutzer-Management
|
||||
- B2.1: Rollen-Edit (admin/vorstand/trainer/newsletter) in `CmsBenutzerScreen` (Inline-Action oder Detail-Dialog)
|
||||
- B2.2: Aktiv/Inaktiv Toggle + Resend-Invite (falls API unterstützt)
|
||||
- B2.3: Tests: `CmsViewModel.users()` Verhalten bei Pagination/Leeren Listen
|
||||
- B3: Kontaktanfragen
|
||||
- B3.1: Detailansicht mit Antwort-Option (falls Backend Mail-Sende-Endpunkt vorhanden)
|
||||
- B3.2: Status-Filter (offen/beantwortet) und Bulk-Archiv
|
||||
- B4: Newsletter
|
||||
- B4.1: Entwurf -> Senden Flow mit Preview (falls Backend zulässt)
|
||||
- B4.2: Gruppenverwaltung (CRUD) + Subscribe/Unsubscribe-Preview
|
||||
- B5: Config / Seiten (Inhalte)
|
||||
- B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis
|
||||
- B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert)
|
||||
- B5.6: Android-Startseite weiter ausbauen: Nutzer sollen Elemente und Reihenfolge der Startseite selbst zusammenstellen koennen; Detailkonzept und Feinschliff folgen spaeter
|
||||
- B6: Diagnostics / Passwort-Reset-Diagnose
|
||||
- B6.1: Detail-View mit exportierbaren Logs (bei Bedarf)
|
||||
- C1: Offline-/Caching-Strategie
|
||||
- C1.1: Verschlüsseltes lokales Caching für CMS-Daten (EncryptedSharedPreferences/Room)
|
||||
- C1.2: Sync-Strategie: lokale Änderungen buffernd senden, Konflikt-UI
|
||||
- C2: Tests & CI
|
||||
- C2.1: ViewModel-Unit-Tests für alle CMS-Flows
|
||||
- C2.2: Compose-UI-Tests für kritische Pfade (News erstellen, Benutzerrolle ändern, Config speichern)
|
||||
- C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||
|
||||
Minor UX-Verbesserungen (parallel möglich):
|
||||
- konsistente Buttons/Labels (`Speichern` vs `Inhalt speichern`), Ladezustand-UI, einzeilige Success-/Error-Banner, Inline-Validierungen.
|
||||
|
||||
Deliverables & Milestones:
|
||||
- M1 (nach Analyse): Priorisierte Aufgabenliste + Schätzung (mehrere PRs)
|
||||
- M2 (nach MVP-Implementierung): News + Benutzer + ContactRequests + Config Editor + Tests (smoke)
|
||||
- M3 (Final): Offline, UI-Tests, Accessibility, Performance
|
||||
|
||||
Zeitplanung (empfohlen):
|
||||
- Analyse: 2 Arbeitstage
|
||||
- MVP-Implementierung: 7–10 Arbeitstage
|
||||
- Hardening + Tests: 3–5 Arbeitstage
|
||||
|
||||
Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Tracker (oder als separate TODOs) ein und beginne mit A1/A2.
|
||||
|
||||
**TODO (zum Abhaken) — CMS-Implementierung**
|
||||
|
||||
- [x] A1: Audit `CmsViewModel` vs Backend-Responses (Fehleraggregation implementiert)
|
||||
- [x] A2: Standardisiere API-Fehlerdarstellung in UI (`FormMessages` / globale Errors)
|
||||
- [x] A3: Roundtrip-Tests `NativeRichTextEditor` ↔ Backend-HTML (Kompatibilität / Quill)
|
||||
- [x] B1: News-Management
|
||||
- [x] B1.1: News-CRUD (Create/Update/Delete) mit RichText-Vorschau
|
||||
- [x] B1.2: Bulk-Aktionen (sichtbar/unsichtbar, expiresAt)
|
||||
- [x] B1.3: Unit-Tests für `NewsViewModel`
|
||||
|
||||
- [x] B2: Benutzer-Management
|
||||
- [x] B2.1: Rollen-Edit (Inline oder Detail-Dialog)
|
||||
- [x] B2.2: Aktiv/Inaktiv Toggle, Resend-Invite
|
||||
- [x] B2.3: Tests für Pagination/Leere Listen
|
||||
|
||||
- [x] B3: Kontaktanfragen
|
||||
- [x] B3.1: Detailansicht + Antwort-Option
|
||||
- [x] B3.2: Status-Filter + Archiv
|
||||
|
||||
- [ ] B4: Newsletter
|
||||
- [x] B4.1: Entwurf → Senden Flow mit Preview
|
||||
- [x] B4.2: Gruppenverwaltung (CRUD)
|
||||
|
||||
- [x] B5: Config / Seiten
|
||||
- Web‑Status: Die Web‑UI bietet bereits umfassende CMS‑UIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSV‑Import/Export, Tabbed‑UIs, ImageUpload, native‑like Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSV‑Export/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher Config‑Editor.
|
||||
- Android‑Status: Implementiert — die Android‑App enthält native CMS‑Screens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/Load‑Flows via `CmsViewModel`.
|
||||
- Umsetzung (B5.x):
|
||||
- [x] B5.1: `cms/startseite` (Startseiten‑Layout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`).
|
||||
- [x] B5.2: `cms/vereinsmeisterschaften` — CSV‑Parser/CSV‑Save integration and modal CRUD (native UI present).
|
||||
- [x] B5.3: `cms/sportbetrieb` — Tabbed UI reusing `Termine`, `Mannschaften`, `Spielplan` components.
|
||||
- [x] B5.4: `cms/einstellungen` — Tabbed config editor with Vereinsdaten/Training/Trainer/Mitgliedschaft and save.
|
||||
- [x] B5.5: Roundtrip & Tests — basic ViewModel unit tests and roundtrip checks exist; Compose UI smoke tests remain for hardening.
|
||||
- [x] B5.6: Startseite weiter ausgebaut — zusaetzliche Elemente (`training`, `links`, `vereinsmeisterschaften`) sind konfigurierbar; Android kann Reihenfolge/Sichtbarkeit lokal speichern und Web nutzt Marker (`cookie`, `eingeloggt`) mit marker-spezifischer Persistenz: `eingeloggt` wird als individuelles User-Setting serverseitig gespeichert, `cookie` wird ausschliesslich im Browser-Cookie gehalten. Neu umgesetzt: konfigurierbare Startseiten-Widgets vom Typ `spielplan_team` (Saison + Mannschaft beim Hinzufuegen waehlbar, spaeter jederzeit aenderbar, mehrfach pro Startseite moeglich, persistiert ueber `key` + `config`).
|
||||
|
||||
- [x] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
|
||||
- Web‑Status: `cms/passwort-reset-diagnose` zeigt vollständige Diagnose‑UI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren Reset‑Versuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
|
||||
- Android‑Status: umgesetzt — native Diagnose‑UI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`‑Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und Share‑Export der maskierten Logs.
|
||||
- Konkrete Android‑ToDos (B6.x):
|
||||
- [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, Status‑Badges und Details.
|
||||
- [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: E‑Mail Maskierung beibehalten.
|
||||
|
||||
- [x] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
|
||||
- Umgesetzt: EncryptedSharedPreferences-basierter Offline-Cache mit Zeitstempel/TTL pro Cache-Key (CMS standard 24h, Reset-Diagnose 6h).
|
||||
- Umgesetzt: Fallback auf verschlüsselte Cache-Daten bei Ladefehlern nur innerhalb der TTL, um veraltete geschützte CMS-Daten zu begrenzen.
|
||||
- Umgesetzt: Gezielte Cache-Invalidierung bei schreibenden CMS-Operationen (Konfiguration, Benutzerverwaltung, Kontaktanfragen, Newsletter, interne News), damit Offline-Daten nach Änderungen konsistent bleiben.
|
||||
- Umgesetzt: Passwort-Reset-Diagnose-Cache wird nur für den Standardfilter (ohne Suchbegriff) verwendet, um falsche Treffer bei gefilterten Diagnosen zu vermeiden.
|
||||
- [x] C2: Tests & CI
|
||||
- [x] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
|
||||
- Status: `:app:testLocalDebugUnitTest` läuft grün; `CmsViewModelTest` wurde auf aktuelle Repository-Signaturen und vollständige `load()`-Abhängigkeiten (inkl. `vereinsmeisterschaften`) aktualisiert.
|
||||
- [x] C2.2: Compose-UI-Tests für kritische Flows
|
||||
- Status: neuer Instrumentation-Test für `CmsPasswordResetDiagnosticsScreen` ergänzt (`diagnosticsScreen_showsFilterAndAttemptDetails`) und gezielt per `connectedLocalDebugAndroidTest` erfolgreich ausgeführt.
|
||||
- [x] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||
- Status: androidTest-ApiService-Stubs und Hilt-Testmodul auf neue `passwordResetDiagnostics(email, failedOnly)`-Signatur erweitert; `:app:assembleLocalDebugAndroidTest` läuft grün.
|
||||
|
||||
Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen.
|
||||
|
||||
84
ANDROID_PORT_TODO.md
Executable file
84
ANDROID_PORT_TODO.md
Executable file
@@ -0,0 +1,84 @@
|
||||
ANDROID App - 1:1 Portierung der Web-UI (TODO)
|
||||
|
||||
Ziel: Die Web-UI des Projekts 1:1 in eine native (oder cross-platform) Android-App überführen, inklusive Farben, Designsystem und aller Funktionalitäten.
|
||||
|
||||
1) Analyse Codebasis & Assets
|
||||
- Analysiere `package.json`, `nuxt.config.js`, `tailwind.config.js` und zentrale Server-/API-Endpunkte.
|
||||
- Liste alle verwendeten Farben, CSS-Variablen, Tailwind-Konfigurationen.
|
||||
- Sammle alle statischen Assets: Bilder, Icons, SVGs, Fonts, PDF-Dokumente.
|
||||
- Identifiziere dynamische Komponenten: Formulare, Rich-Text-Editor, Uploads, Kalender, Navigation.
|
||||
|
||||
2) Projektziele und Scope
|
||||
- Entscheide: Native Android (Kotlin/Jetpack Compose) oder Cross-Platform (React Native, Flutter, Kotlin Multiplatform).
|
||||
- Priorisiere Features für MVP vs. Post-Launch.
|
||||
|
||||
3) Designsystem und Farben extrahieren
|
||||
- Extrahiere Farbpalette, Typografie, Abstände, Buttons, Karten, Form-Controls.
|
||||
- Erstelle eine Design-Token-Liste (Hex/RGBA, Namen, Einsatzbereiche).
|
||||
|
||||
4) Technologie-Stack wählen
|
||||
- Empfohlene Optionen: Kotlin + Jetpack Compose (native), Flutter (UI-First), React Native (Wiederverwendung von JS/nuxt-Logik).
|
||||
- Bibliotheken: Navigation, HTTP-Client, Bild-Handling, Auth (WebAuthn falls nötig), Local DB.
|
||||
|
||||
5) Android-Projekt aufsetzen
|
||||
- Erstelle Projekt-Scaffold, CI-Build, Signing-Config.
|
||||
|
||||
6) Theme & Farben implementieren
|
||||
- Implementiere App-Theme mit Farben/Typografie-Token.
|
||||
|
||||
7) Navigation-Struktur implementieren
|
||||
- Bottom/Navigations-Drawer/Stack wie Web-Navigation abbilden.
|
||||
|
||||
8) Screens für Seiten anlegen
|
||||
- Erstelle Screens für: Startseite, Termine, Spielplan, Galerie, Kontakt, News, Mitgliedschaft, Login, CMS-Bereiche.
|
||||
|
||||
9) UI-Komponenten portieren
|
||||
- Navbar, Footer, Cards, Image-Grid, Modal/Dialog, Rich-Text-Viewer/Editor, Date-Picker, Tabellen.
|
||||
|
||||
10) Formulare & Validierung implementieren
|
||||
- Registrieren, Login, Passwort vergessen, Mitgliedschaftsformulare mit Client- und Server-Validierung.
|
||||
|
||||
11) Authentifizierungs-Flow implementieren
|
||||
- JWT / Session, OAuth oder WebAuthn falls benötigt; Token-Handling sicher speichern.
|
||||
|
||||
12) API-Client implementieren
|
||||
- Einheitlicher HTTP-Client, Error-Handling, Retry-Strategien, Pagination.
|
||||
|
||||
13) Bilderupload & Storage einrichten
|
||||
- Multi-part Upload, Progress, Bildkompression, lokale Cache-Strategie.
|
||||
|
||||
14) Offline-Support und Caching
|
||||
- Caching von API-Responses, Bild-Caching, Sync-Strategie für Formulare.
|
||||
|
||||
15) Lokalisierung und Texte prüfen
|
||||
- Alle statischen Texte extrahieren, deutsche Strings prüfen und in Resource-Files ablegen.
|
||||
|
||||
16) Accessibility-Prüfung und Anpassungen
|
||||
- Farbkontrast, Touch-Targets, Screenreader-Labels.
|
||||
|
||||
17) Unit- und UI-Tests schreiben
|
||||
- Komponenten- und Integrations-Tests, E2E (falls möglich).
|
||||
|
||||
18) Performance-Optimierung durchführen
|
||||
- Bilder, Netzwerk, Render-Perf.
|
||||
|
||||
19) CI/CD für Builds einrichten
|
||||
- GitHub Actions / GitLab CI: Build, Test, Lint, Release.
|
||||
|
||||
20) Play Store Release vorbereiten
|
||||
- App-Icons, Screenshots, Privacy-Policy, Datensparsamkeit.
|
||||
|
||||
21) Monitoring & Crash-Reporting einrichten
|
||||
- Sentry / Firebase Crashlytics, Analytics.
|
||||
|
||||
22) Dokumentation: Setup & Architektur
|
||||
- README, Architekturdiagramm, API-Spec, Onboarding-Guide.
|
||||
|
||||
23) Design Review und Abnahme
|
||||
- UX/Design-Review mit Stakeholdern.
|
||||
|
||||
24) Launch und Feedbackrunde durchführen
|
||||
- Release-Notes, Feedback-Formular, Bug-Fixing-Plan.
|
||||
|
||||
|
||||
Datei erstellt: Bitte bestätige, wenn ich mit der in-depth Analyse der Codebasis und Assets beginnen soll (Suche nach Farben, verwendeten Komponenten, Images, Fonts, relevanten Scripts).
|
||||
58
ANDROID_REPO_ENDPOINTS.md
Executable file
58
ANDROID_REPO_ENDPOINTS.md
Executable file
@@ -0,0 +1,58 @@
|
||||
Repo API Endpoints — Übersicht
|
||||
|
||||
Hinweis: Viele Frontend-Requests verwenden relative Pfade (`/api/...`) und Nuxt's `NUXT_PUBLIC_BASE_URL`.
|
||||
|
||||
Öffentliche/Frontend-Endpunkte (häufig genutzt):
|
||||
- GET /api/config
|
||||
- GET /api/news-public
|
||||
- GET /api/news
|
||||
- GET /api/termine
|
||||
- GET /api/spielplaene
|
||||
- GET /api/spielplan
|
||||
- GET /api/mannschaften
|
||||
- GET /api/galerie
|
||||
- GET /api/media/galerie/{id}
|
||||
- GET /api/personen/{filename}?width=...&height=...
|
||||
- POST /api/contact
|
||||
- POST /api/news (CMS)
|
||||
|
||||
Galerie / Media:
|
||||
- POST /api/galerie/upload
|
||||
- GET /api/galerie/list
|
||||
- GET /api/galerie/[id]
|
||||
- DELETE /api/galerie/[id]
|
||||
|
||||
Authentifizierung:
|
||||
- POST /api/auth/login
|
||||
- POST /api/auth/logout
|
||||
- POST /api/auth/register
|
||||
- POST /api/auth/reset-password
|
||||
- GET /api/auth/status
|
||||
- POST /api/auth/passkeys/authentication-options (Passkeys start: server returns WebAuthn options)
|
||||
- POST /api/auth/passkeys/login (Passkeys finish: credential verification)
|
||||
|
||||
CMS / geschützte Endpunkte (erfordern Auth):
|
||||
- GET /api/cms/* (z.B. /api/cms/users/list, /api/cms/contact-requests)
|
||||
- POST /api/cms/save-csv
|
||||
- POST /api/cms/upload-spielplan-pdf
|
||||
- POST /api/cms/satzung-upload
|
||||
- POST /api/members, DELETE /api/members, POST /api/members/bulk
|
||||
- POST /api/membership/update-status
|
||||
- POST /api/termine-manage, DELETE /api/termine-manage, GET /api/termine-manage
|
||||
|
||||
Weitere (Datei-Uploads, Personen):
|
||||
- POST /api/personen/upload
|
||||
- GET /api/app/version
|
||||
- Various CMS-specific routes under /api/cms
|
||||
|
||||
Auth-Anforderungen & Hinweise:
|
||||
- Frontend nutzt `$fetch('/api/...')` (Nuxt) — serverseitig vermutlich Session-Cookie oder JWT.
|
||||
- `stores/auth.js` verwendet `/api/auth/status` to check login state and `passkeyLogin()` which calls `/api/auth/passkeys/*`.
|
||||
- Passkeys-Flow verwendet `@simplewebauthn/browser` on web; Android port should support FIDO2 / Passkeys (Google Passkeys API) or provide password fallback.
|
||||
- CMS- und Manage-Endpunkte require authentication and role checks (admin/vorstand etc.).
|
||||
|
||||
Empfehlung für Android-Client:
|
||||
- Nutze Retrofit/OkHttp mit anpassbarem Auth-Interceptor (Cookie-jar or token storage). Prüfe, ob Server bevorzugt Cookies (then use CookieJar) or JWT Authorization header.
|
||||
- Implementiere Passkeys via Android FIDO2 / Passkeys APIs as optional fast-login path; for servers expecting WebAuthn payloads adapt encoding accordingly.
|
||||
|
||||
Datei automatisch erzeugt — wenn du möchtest, kann ich nun alle Dateien in `public/` und `assets/` auflisten und exportieren (Bilder, Fonts, PDFs).
|
||||
0
AUTH_README.md
Normal file → Executable file
0
AUTH_README.md
Normal file → Executable file
0
DATENSCHUTZ.md
Normal file → Executable file
0
DATENSCHUTZ.md
Normal file → Executable file
0
DATENSCHUTZ_UEBERSICHT.md
Normal file → Executable file
0
DATENSCHUTZ_UEBERSICHT.md
Normal file → Executable file
0
DEPLOYMENT.md
Normal file → Executable file
0
DEPLOYMENT.md
Normal file → Executable file
32
PLAYSTORE_REVIEW.md
Normal file
32
PLAYSTORE_REVIEW.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Google Play: Zugang für die App-Prüfung
|
||||
|
||||
In der Play Console unter **Richtlinien und Programme → App-Inhalte → App-Zugriff / Anmeldedaten** angeben, dass einige Funktionen eine Anmeldung erfordern. Eine neue Anleitung mit Titel `Harheimer TC review account` anlegen.
|
||||
|
||||
## Konto auf dem Backend der eingereichten App einrichten
|
||||
|
||||
Nach Deployment dieser Änderungen im Projektverzeichnis des entsprechenden Servers ausführen:
|
||||
|
||||
```bash
|
||||
node scripts/setup-playstore-review.js
|
||||
```
|
||||
|
||||
Das Skript liest die dortige `.env` einschließlich `ENCRYPTION_KEY`, sichert die bisherige Benutzerdatei und erstellt ein aktives, ausgeblendetes Mitgliedskonto. Standardadresse: `playstore-review@harheimertc.invalid`. Dies ist bewusst kein echtes Postfach. Das zufällig erzeugte Passwort wird einmal ausgegeben und muss sicher abgelegt werden. Bei erneutem Aufruf bleibt es unverändert. Optional können `PLAYSTORE_REVIEW_EMAIL` und `PLAYSTORE_REVIEW_PASSWORD` gesetzt werden; ein explizites Passwort aktualisiert das bestehende Review-Konto. Kein reguläres Konto wird umgewandelt.
|
||||
|
||||
In der Play Console die ausgegebene E-Mail-Adresse und das Passwort eintragen. Zusätzliche Anweisungen auf Englisch:
|
||||
|
||||
> Open the app and go to Login. Sign in with the email address and password provided above. This account is already activated and provides access to the member features. No registration, email verification, one-time code or approval is required. Please stay signed in while testing forms. Outgoing emails triggered by this account are suppressed by the server. When a form asks for an email address, use the review account email provided above, including when testing while signed out.
|
||||
|
||||
Die Zugangsdaten dauerhaft gültig halten. Das Konto ist ein Mitglied, kein CMS-Administrator.
|
||||
|
||||
## Versandsperre
|
||||
|
||||
Alle SMTP-Versandstellen verwenden dieselbe serverseitige Sperre:
|
||||
|
||||
- Für authentifizierte Konten mit `accountType: playstore_review` oder der konfigurierten `PLAYSTORE_REVIEW_EMAIL` wird kein SMTP-Versand ausgeführt, auch bei abweichender Formularadresse.
|
||||
- Mails an die Review-Adresse sowie Kontaktmails mit dieser Antwortadresse werden auch ohne Anmeldung unterdrückt. Registrierung und Mitgliedsanträge prüfen zusätzlich die Formularadresse. Gemischte Empfängerlisten mit einem Review-Konto werden vollständig unterdrückt.
|
||||
- `MAIL_DELIVERY_DISABLED=true` unterdrückt sämtliche SMTP-Mails einer Installation. Die PM2-Testkonfiguration setzt dies standardmäßig; Produktion bleibt ohne diese Einstellung aktiv. Nach Konfigurationsänderungen den Backend-Prozess mit aktualisierter Umgebung neu starten.
|
||||
- `DEBUG=true` allein verhindert keinen Mailversand.
|
||||
|
||||
Die Versandsperre ist keine isolierte Demo-Datenbank: Formulare können weiterhin Daten speichern und Push-Benachrichtigungen auslösen. Ohne Anmeldung und mit einer fremden Formularadresse lässt sich eine Anfrage keinem Review-Konto zuordnen. Für vollständig mailfreie Tests daher eine separate Testinstanz mit `MAIL_DELIVERY_DISABLED=true` verwenden und sicherstellen, dass der eingereichte App-Build dieses Backend nutzt.
|
||||
|
||||
Google-Anforderungen: https://support.google.com/googleplay/android-developer/answer/15748846
|
||||
92
android-app/PLAYSTORE_ASSETS.md
Executable file
92
android-app/PLAYSTORE_ASSETS.md
Executable file
@@ -0,0 +1,92 @@
|
||||
# Play Store Assets - Harheimer TC Android
|
||||
|
||||
## 1) Datenschutzerklaerung (Web-URL)
|
||||
|
||||
Empfohlene URL fuer Play Console:
|
||||
- https://harheimertc.de/datenschutz
|
||||
|
||||
Die Seite ist in der Web-App als eigene Route vorhanden.
|
||||
|
||||
## 1b) Konto-Loeschung (Web-URL)
|
||||
|
||||
Empfohlene URL fuer Play Console:
|
||||
- https://harheimertc.de/konto-loeschen
|
||||
|
||||
Die Seite beschreibt den Loeschprozess und Kontaktweg fuer App- und Webkonto.
|
||||
|
||||
## 2) Logo / Grafiken
|
||||
|
||||
### Pflicht
|
||||
- App-Icon (Play): 512 x 512 PNG
|
||||
|
||||
### Optional, aber empfohlen
|
||||
- Feature Graphic: 1024 x 500 PNG
|
||||
|
||||
### Generierung
|
||||
|
||||
Im Repo ist ein Script vorhanden, das aus dem Vereinslogo fertige Dateien erzeugt:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-assets.sh
|
||||
```
|
||||
|
||||
Ausgabe in:
|
||||
- android-app/playstore-assets/generated/playstore-icon-512.png
|
||||
- android-app/playstore-assets/generated/playstore-feature-graphic-1024x500.png
|
||||
|
||||
## 3) Screenshots (anonymisiert)
|
||||
|
||||
### Zielgroessen fuer Store-Upload
|
||||
- Telefon (Portrait): 1080 x 1920
|
||||
- Medium 7" Tablet (Portrait): 1200 x 1920
|
||||
- 10" Tablet (Portrait): 1600 x 2560
|
||||
|
||||
Alle Dateien als PNG oder JPEG.
|
||||
|
||||
### Anonymisierung
|
||||
|
||||
Script fuer schwarze halbtransparente Balken ueber sensible Bereiche:
|
||||
|
||||
```bash
|
||||
./scripts/anonymize-playstore-screenshot.sh <input.png> <output.png> 'x,y,w,h;x,y,w,h'
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
./scripts/anonymize-playstore-screenshot.sh \
|
||||
android-app/playstore-assets/raw/screen1.png \
|
||||
android-app/playstore-assets/anon/screen1-anon.png \
|
||||
'68,118,520,72;70,706,560,98'
|
||||
```
|
||||
|
||||
### Zielprofile erzeugen (Telefon, 7", 10")
|
||||
|
||||
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh
|
||||
```
|
||||
|
||||
Optional mit eigenen Ordnern:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh \
|
||||
--input-dir android-app/playstore-assets/anon \
|
||||
--output-dir android-app/playstore-assets/final
|
||||
```
|
||||
|
||||
Output:
|
||||
- android-app/playstore-assets/final/phone
|
||||
- android-app/playstore-assets/final/tablet-7
|
||||
- android-app/playstore-assets/final/tablet-10
|
||||
|
||||
## 4) Upload in Play Console
|
||||
|
||||
- Datenschutzerklaerung: URL eintragen
|
||||
- Konto-Loeschung: URL eintragen
|
||||
- App-Icon: playstore-icon-512.png
|
||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||
- Screenshots Telefon: Dateien aus `.../final/phone`
|
||||
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
|
||||
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`
|
||||
296
android-app/app/build.gradle.kts
Executable file
296
android-app/app/build.gradle.kts
Executable file
@@ -0,0 +1,296 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.google.devtools.ksp")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.dagger.hilt.android")
|
||||
}
|
||||
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||
.orElse("https://harheimertc.tsschulz.de/")
|
||||
.get()
|
||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||
.orElse("https://harheimertc.de/")
|
||||
.get()
|
||||
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
|
||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||
.orElse("")
|
||||
.get()
|
||||
val androidVersionCode = providers.gradleProperty("ANDROID_VERSION_CODE")
|
||||
.orElse("2")
|
||||
.get()
|
||||
.toInt()
|
||||
val androidVersionName = providers.gradleProperty("ANDROID_VERSION_NAME")
|
||||
.orElse("1.0.0")
|
||||
.get()
|
||||
val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
||||
.orElse("true")
|
||||
.get()
|
||||
.toBoolean()
|
||||
|
||||
val localSigningProperties = Properties().apply {
|
||||
val localSigningFile = rootProject.file("gradle-local.properties")
|
||||
if (localSigningFile.exists()) {
|
||||
localSigningFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun signingProperty(name: String): String? =
|
||||
providers.gradleProperty(name).orNull
|
||||
?: providers.environmentVariable(name).orNull
|
||||
?: localSigningProperties.getProperty(name)
|
||||
|
||||
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
|
||||
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
|
||||
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
|
||||
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
|
||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||
!releaseStorePassword.isNullOrBlank() &&
|
||||
!releaseKeyAlias.isNullOrBlank() &&
|
||||
!releaseKeyPassword.isNullOrBlank()
|
||||
|
||||
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
||||
doFirst {
|
||||
if (!hasReleaseSigning) {
|
||||
throw GradleException(
|
||||
"Production release signing is not configured. " +
|
||||
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
|
||||
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||
doFirst {
|
||||
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
|
||||
throw GradleException(
|
||||
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.harheimertc"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = androidVersionCode
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf(
|
||||
"AutoboxingStateCreation",
|
||||
"MutableCollectionMutableState",
|
||||
)
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
storeFile = file(releaseStoreFile!!)
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = releaseMinifyEnabled
|
||||
isShrinkResources = false
|
||||
ndk {
|
||||
// Generate a native debug symbols archive for Play Console uploads.
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "environment"
|
||||
productFlavors {
|
||||
create("local") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".local"
|
||||
versionNameSuffix = "-local"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
create("instantTest") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".test"
|
||||
versionNameSuffix = "-test"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$productionApiBaseUrl\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
// allow Byte Buddy experimental features for newer JVMs
|
||||
it.jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("packageNativeDebugSymbolsForProductionRelease") {
|
||||
group = "distribution"
|
||||
description = "Packages production release native libraries into a Play Console debug symbols zip."
|
||||
dependsOn(":app:mergeProductionReleaseNativeLibs")
|
||||
from(layout.buildDirectory.dir("intermediates/merged_native_libs/productionRelease/mergeProductionReleaseNativeLibs/out/lib"))
|
||||
destinationDirectory.set(layout.buildDirectory.dir("outputs/native-debug-symbols/productionRelease"))
|
||||
archiveFileName.set("native-debug-symbols.zip")
|
||||
}
|
||||
|
||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
group = "distribution"
|
||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||
dependsOn(ensureReleaseSigning)
|
||||
dependsOn(ensureProductionApiBaseUrl)
|
||||
dependsOn(":app:bundleProductionRelease")
|
||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||
|
||||
doLast {
|
||||
val outputDir = layout.buildDirectory.dir("outputs/playstore/productionRelease").get().asFile
|
||||
outputDir.mkdirs()
|
||||
|
||||
val bundleFile = layout.buildDirectory.file("outputs/bundle/productionRelease/app-production-release.aab").get().asFile
|
||||
if (bundleFile.exists()) {
|
||||
bundleFile.copyTo(outputDir.resolve(bundleFile.name), overwrite = true)
|
||||
}
|
||||
|
||||
val mappingFile = layout.buildDirectory.file("outputs/mapping/productionRelease/mapping.txt").get().asFile
|
||||
if (mappingFile.exists()) {
|
||||
mappingFile.copyTo(outputDir.resolve("mapping.txt"), overwrite = true)
|
||||
}
|
||||
|
||||
val nativeSymbolsZip = layout.buildDirectory.file("outputs/native-debug-symbols/productionRelease/native-debug-symbols.zip").get().asFile
|
||||
if (nativeSymbolsZip.exists()) {
|
||||
nativeSymbolsZip.copyTo(outputDir.resolve("native-debug-symbols.zip"), overwrite = true)
|
||||
}
|
||||
|
||||
println("Play Store artifacts prepared in: ${outputDir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching {
|
||||
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||
}.configureEach {
|
||||
dependsOn(ensureReleaseSigning)
|
||||
dependsOn(ensureProductionApiBaseUrl)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
implementation("androidx.core:core-ktx:1.10.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
// Compose
|
||||
implementation("androidx.compose.ui:ui:1.5.0")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.5.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling:1.5.0")
|
||||
implementation("androidx.compose.material3:material3:1.1.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.6.0")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
|
||||
|
||||
// Lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.59.2")
|
||||
ksp("com.google.dagger:hilt-compiler:2.59.2")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
||||
|
||||
// Passkeys / Credential Manager
|
||||
implementation("androidx.credentials:credentials:1.6.0")
|
||||
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
|
||||
|
||||
// Coil
|
||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||
|
||||
// Crash reporting
|
||||
implementation("io.sentry:sentry-android:8.42.0")
|
||||
|
||||
// Push notifications
|
||||
implementation("com.google.firebase:firebase-messaging:25.0.2")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
|
||||
// WorkManager, DataStore
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
|
||||
// Testing (skeleton)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
testImplementation("io.mockk:mockk:1.13.7")
|
||||
// Compose UI testing
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||
// Hilt testing
|
||||
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
|
||||
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated
|
||||
androidTestImplementation("com.google.dagger:hilt-android:2.59.2")
|
||||
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.0")
|
||||
}
|
||||
1
android-app/app/google-services.json
Symbolic link
1
android-app/app/google-services.json
Symbolic link
@@ -0,0 +1 @@
|
||||
../../google-services.json
|
||||
37
android-app/app/proguard-rules.pro
vendored
Executable file
37
android-app/app/proguard-rules.pro
vendored
Executable file
@@ -0,0 +1,37 @@
|
||||
# Project-specific R8/ProGuard rules for release builds.
|
||||
|
||||
# Keep reflection/generic metadata used by Retrofit + Moshi.
|
||||
-keepattributes Signature,InnerClasses,EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# Keep Retrofit service interfaces and HTTP method annotations.
|
||||
-keep,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface <1>
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# Avoid Retrofit generic signature loss on release builds for our API interface.
|
||||
-keep interface de.harheimertc.data.ApiService { *; }
|
||||
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
|
||||
|
||||
# Keep app DTO/request/response models used via Moshi reflection.
|
||||
-keep class de.harheimertc.data.*Dto { *; }
|
||||
-keep class de.harheimertc.data.*Request { *; }
|
||||
-keep class de.harheimertc.data.*Response { *; }
|
||||
|
||||
# Keep fields annotated with @Json names.
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
|
||||
# Keep WorkManager + Room generated classes used reflectively at startup.
|
||||
-keep class * extends androidx.work.ListenableWorker {
|
||||
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||
}
|
||||
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
7
android-app/app/src/androidTest/AndroidManifest.xml
Executable file
7
android-app/app/src/androidTest/AndroidManifest.xml
Executable file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application android:allowBackup="false">
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Disabled TestBindingsModule — replaced by TestHiltModules.kt
|
||||
// Kept as an empty placeholder to avoid accidental compilation of the previous
|
||||
// broken test module. Refer to TestHiltModules.kt for test bindings.
|
||||
package de.harheimertc.test
|
||||
|
||||
// Intentionally empty
|
||||
90
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Executable file
90
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Executable file
@@ -0,0 +1,90 @@
|
||||
package de.harheimertc.test
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthUserDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import dagger.hilt.InstallIn
|
||||
import retrofit2.Response
|
||||
import javax.inject.Singleton
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.repositories.AuthRepository as RepoAuthRepository
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [de.harheimertc.data.NetworkModule::class, de.harheimertc.di.RepositoryModule::class]
|
||||
)
|
||||
object TestHiltModules {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder().build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(): ApiService {
|
||||
val handler = InvocationHandler { _, method: Method, args: Array<Any>? ->
|
||||
when (method.name) {
|
||||
"login" -> Response.success(LoginResponse(success = true, accessToken = "test-token", refreshToken = "r", sessionId = "s", user = AuthUserDto(id = "1", email = "test@example.com", name = "Test")))
|
||||
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
|
||||
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
|
||||
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
|
||||
}
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
ApiService::class.java.classLoader,
|
||||
arrayOf(ApiService::class.java),
|
||||
handler,
|
||||
) as ApiService
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(): AuthRepository = object : AuthRepository {
|
||||
private var token: String? = "test-token"
|
||||
private var refresh: String? = "r"
|
||||
override fun getToken(): String? = token
|
||||
override fun getRefreshToken(): String? = refresh
|
||||
override fun getSessionId(): String? = "s"
|
||||
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||
token = accessToken
|
||||
refresh = refreshToken
|
||||
}
|
||||
|
||||
override fun clearSession() { token = null; refresh = null }
|
||||
override fun ensureDeviceKey(): String? = null
|
||||
override fun getDevicePublicKey(): String? = null
|
||||
override fun signWithDeviceKey(data: ByteArray): ByteArray? = null
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionRefresher(auth: AuthRepository, moshi: Moshi): SessionRefresher = SessionRefresher(auth, moshi)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoginRepository(api: ApiService, auth: AuthRepository, sessionRefresher: SessionRefresher): LoginRepository {
|
||||
return LoginRepository(api, auth, sessionRefresher)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePasskeyRepository(api: ApiService, auth: AuthRepository): PasskeyRepository {
|
||||
return PasskeyRepository(api, auth)
|
||||
}
|
||||
}
|
||||
5
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Executable file
5
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Executable file
@@ -0,0 +1,5 @@
|
||||
package de.harheimertc.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
class TestActivity : ComponentActivity()
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsActivateResendTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun activateAndResend_buttonsAreClickable() {
|
||||
composeTestRule.setContent {
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Deaktivieren") }
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Invite erneut") }
|
||||
}
|
||||
|
||||
// wait until nodes appear to avoid race conditions on slower devices
|
||||
fun waitForText(text: String, timeoutMs: Long = 15000L) {
|
||||
try {
|
||||
composeTestRule.waitUntil(timeoutMs) {
|
||||
try {
|
||||
composeTestRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (_: AssertionError) {
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// dump semantics tree for debugging before failing
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE")
|
||||
} catch (_: Throwable) { /* best-effort logging */ }
|
||||
throw AssertionError("Timed out waiting for text: '$text'")
|
||||
}
|
||||
}
|
||||
|
||||
// helper: find the nearest parent node that has a click action
|
||||
fun findClickableParent(text: String): SemanticsNodeInteraction {
|
||||
val all = composeTestRule.onAllNodes(hasText(text))
|
||||
if (all.fetchSemanticsNodes().isEmpty()) {
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NOT-FOUND-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No node found with text '$text'")
|
||||
}
|
||||
|
||||
// Log matches for debugging
|
||||
try {
|
||||
val matches = all.fetchSemanticsNodes()
|
||||
Log.d("CmsActivateResendTest", "Found ${matches.size} node(s) for text '$text'")
|
||||
matches.forEachIndexed { i, n -> Log.d("CmsActivateResendTest", "Match[$i]: ${n}") }
|
||||
} catch (_: Throwable) { /* ignore logging failures */ }
|
||||
|
||||
var node = try {
|
||||
// prefer the single-node API, but fall back to the first match if ambiguous
|
||||
composeTestRule.onNode(hasText(text))
|
||||
} catch (_: AssertionError) {
|
||||
all[0]
|
||||
}
|
||||
|
||||
// climb a few parents to find the clickable wrapper
|
||||
repeat(8) {
|
||||
try {
|
||||
node.assert(hasClickAction())
|
||||
try { Log.d("CmsActivateResendTest", "Clickable node found for '$text': ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
return node
|
||||
} catch (_: AssertionError) {
|
||||
try { Log.d("CmsActivateResendTest", "Node not clickable yet, current node: ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
node = node.onParent()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NO-CLICK-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No clickable parent found for text '$text'")
|
||||
}
|
||||
|
||||
waitForText("Deaktivieren")
|
||||
val deactivateNode = findClickableParent("Deaktivieren")
|
||||
deactivateNode.assertExists()
|
||||
deactivateNode.assertIsDisplayed()
|
||||
deactivateNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
deactivateNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-DEACTIVATE")
|
||||
throw e
|
||||
}
|
||||
|
||||
waitForText("Invite erneut")
|
||||
val inviteNode = findClickableParent("Invite erneut")
|
||||
inviteNode.assertExists()
|
||||
inviteNode.assertIsDisplayed()
|
||||
inviteNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
inviteNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-INVITE")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.ContactResponse
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import de.harheimertc.data.MembershipResponse
|
||||
import de.harheimertc.data.NewsletterCreateRequest
|
||||
import de.harheimertc.data.NewsletterCreateResponse
|
||||
import de.harheimertc.data.NewsletterDto
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.NewsletterSendResponse
|
||||
import de.harheimertc.data.NewsletterSubscriptionRequest
|
||||
import de.harheimertc.data.NewsPublicResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
|
||||
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
|
||||
import de.harheimertc.data.PasskeysResponse
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.ProfileResponse
|
||||
import de.harheimertc.data.ProfileUpdateRequest
|
||||
import de.harheimertc.data.PublicGalleryImageDto
|
||||
import de.harheimertc.data.GalleryListResponse
|
||||
import de.harheimertc.data.GalleryUploadResponse
|
||||
import de.harheimertc.data.RefreshRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.RemovePasskeyRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SaveCsvRequest
|
||||
import de.harheimertc.data.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TeamTableResponse
|
||||
import de.harheimertc.data.TermineResponse
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsExistingScreensSmokeTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsDashboard_renders() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsStartseite_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsInhalte_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsVereinsmeisterschaften_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.saveCsvCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsSportbetrieb_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsEinstellungen_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsMitgliederverwaltung_clickFreischalten() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Freischalten").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.updateUserActiveCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsBenutzer_clickRollenSpeichern() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.updateUserRolesCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsContactRequests_replySenden() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Antworten").performClick()
|
||||
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
|
||||
composeTestRule.onNodeWithText("Senden").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.replyContactCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsNewsletter_createAndSave() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
|
||||
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
|
||||
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.createNewsletterCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsPasswordResetDiagnostics_renders() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
|
||||
}
|
||||
|
||||
private fun createViewModel(api: RecordingApiService): CmsViewModel {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||
val repository = CmsRepository(api, cache)
|
||||
return CmsViewModel(repository)
|
||||
}
|
||||
|
||||
private fun renderWithState(viewModel: CmsViewModel) {
|
||||
val readyState = CmsUiState(
|
||||
loading = false,
|
||||
saving = false,
|
||||
error = null,
|
||||
message = null,
|
||||
config = ConfigResponse(),
|
||||
users = listOf(
|
||||
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||
),
|
||||
contactRequests = listOf(
|
||||
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||
),
|
||||
newsletters = listOf(
|
||||
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||
),
|
||||
newsletterGroups = listOf(
|
||||
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||
),
|
||||
passwordResetAttempts = listOf(
|
||||
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||
),
|
||||
news = emptyList(),
|
||||
meisterschaften = listOf(
|
||||
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
|
||||
),
|
||||
)
|
||||
val field = CmsViewModel::class.java.getDeclaredField("_state")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingApiService : ApiService {
|
||||
var updateConfigCalls = 0
|
||||
var saveCsvCalls = 0
|
||||
var updateUserActiveCalls = 0
|
||||
var updateUserRolesCalls = 0
|
||||
var replyContactCalls = 0
|
||||
var createNewsletterCalls = 0
|
||||
|
||||
private val config = ConfigResponse()
|
||||
private val users = listOf(
|
||||
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||
)
|
||||
private val contactRequests = listOf(
|
||||
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||
)
|
||||
private val newsletters = listOf(
|
||||
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||
)
|
||||
private val groups = listOf(
|
||||
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||
)
|
||||
private val diagnostics = listOf(
|
||||
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||
)
|
||||
|
||||
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
|
||||
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
|
||||
updateConfigCalls++
|
||||
return Response.success(request)
|
||||
}
|
||||
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> =
|
||||
Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\"".toResponseBody(null))
|
||||
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
|
||||
saveCsvCalls++
|
||||
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
|
||||
}
|
||||
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
|
||||
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
|
||||
updateUserRolesCalls++
|
||||
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
|
||||
}
|
||||
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
|
||||
updateUserActiveCalls++
|
||||
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
|
||||
}
|
||||
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
|
||||
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
|
||||
replyContactCalls++
|
||||
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
|
||||
}
|
||||
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
|
||||
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
|
||||
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
|
||||
createNewsletterCalls++
|
||||
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
|
||||
}
|
||||
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun passwordResetDiagnostics(
|
||||
email: String?,
|
||||
failedOnly: Boolean,
|
||||
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.PasswordResetStepDto
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsPasswordResetDiagnosticsScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
|
||||
val api = createDiagnosticsApiService()
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||
val repo = CmsRepository(api, cache)
|
||||
val viewModel = CmsViewModel(repo)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.waitUntil(15_000) {
|
||||
try {
|
||||
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
|
||||
|
||||
// Trigger a manual refresh to validate the main interaction path.
|
||||
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun createDiagnosticsApiService(): ApiService {
|
||||
val attempt = PasswordResetAttemptDto(
|
||||
requestId = "req-1",
|
||||
startedAt = "2026-05-29T10:15:00Z",
|
||||
emailMasked = "m***@example.com",
|
||||
ip = "127.0.0.1",
|
||||
failed = true,
|
||||
steps = listOf(
|
||||
PasswordResetStepDto(
|
||||
ts = "2026-05-29T10:15:01Z",
|
||||
step = "mail_configuration",
|
||||
status = "failed",
|
||||
reason = "smtp_credentials_missing",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val handler = InvocationHandler { _, method: Method, _ ->
|
||||
when (method.name) {
|
||||
"config" -> Response.success(ConfigResponse())
|
||||
"users" -> Response.success(CmsUsersResponse())
|
||||
"cmsUsers" -> Response.success(CmsUsersResponse())
|
||||
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
|
||||
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
|
||||
"passwordResetDiagnostics" -> Response.success(
|
||||
PasswordResetDiagnosticsResponse(
|
||||
retentionHours = 72,
|
||||
searchedEmail = "",
|
||||
matchingUsers = listOf(
|
||||
de.harheimertc.data.PasswordResetMatchingUserDto(
|
||||
id = "u1",
|
||||
name = "Max Muster",
|
||||
email = "max@example.com",
|
||||
active = true,
|
||||
),
|
||||
),
|
||||
attempts = listOf(attempt),
|
||||
),
|
||||
)
|
||||
"vereinsmeisterschaften" -> Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n".toResponseBody(null))
|
||||
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
|
||||
}
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
ApiService::class.java.classLoader,
|
||||
arrayOf(ApiService::class.java),
|
||||
handler,
|
||||
) as ApiService
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsRolesDialogTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
class FakeVm {
|
||||
var calledId: String? = null
|
||||
var calledRoles: List<String>? = null
|
||||
fun updateUserRoles(id: String, roles: List<String>) {
|
||||
calledId = id
|
||||
calledRoles = roles
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rolesDialog_callsUpdateUserRoles() {
|
||||
val fake = FakeVm()
|
||||
val initialRoles = listOf("admin")
|
||||
|
||||
composeTestRule.setContent {
|
||||
val show = remember { mutableStateOf(false) }
|
||||
val selected = remember { mutableStateListOf<String>().apply { addAll(initialRoles) } }
|
||||
Column {
|
||||
Button(onClick = { show.value = true }) { Text("Rollen") }
|
||||
if (show.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { show.value = false },
|
||||
title = { Text("Rollen bearbeiten") },
|
||||
text = {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
// simple checkbox row for admin only (representative)
|
||||
Row {
|
||||
Checkbox(checked = selected.contains("admin"), onCheckedChange = { checked ->
|
||||
if (checked) selected.add("admin") else selected.remove("admin")
|
||||
})
|
||||
Text("admin", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
fake.updateUserRoles("42", selected.toList())
|
||||
show.value = false
|
||||
}) { Text("Speichern") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { show.value = false }) { Text("Abbrechen") } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open dialog
|
||||
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||
// Save immediately (we keep admin preselected)
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
|
||||
assert(fake.calledId == "42")
|
||||
assert(fake.calledRoles?.contains("admin") == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsScreen_placeholder() {
|
||||
composeTestRule.setContent {
|
||||
Text("CMS Placeholder")
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("CMS Placeholder").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import de.harheimertc.data.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsStartseiteSmokeTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsStartseite_rendersWithDefaultState() {
|
||||
// prepare a minimal fake ApiService that returns empty/neutral responses
|
||||
val fakeApi = object : ApiService {
|
||||
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
|
||||
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
|
||||
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
|
||||
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
|
||||
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
|
||||
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
|
||||
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun passwordResetDiagnostics(
|
||||
email: String?,
|
||||
failedOnly: Boolean,
|
||||
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
|
||||
}
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
val moshi = Moshi.Builder().build()
|
||||
val cache = SecureOfflineCache(context, moshi)
|
||||
val repo = CmsRepository(fakeApi, cache)
|
||||
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
|
||||
|
||||
// set a ready state to avoid waiting for async network loads in Vm.init
|
||||
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
|
||||
loading = false,
|
||||
saving = false,
|
||||
error = null,
|
||||
message = null,
|
||||
config = ConfigResponse(),
|
||||
users = emptyList(),
|
||||
contactRequests = emptyList(),
|
||||
newsletters = emptyList(),
|
||||
newsletterGroups = emptyList(),
|
||||
passwordResetAttempts = emptyList(),
|
||||
news = emptyList(),
|
||||
)
|
||||
try {
|
||||
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
|
||||
field.isAccessible = true
|
||||
val current = field.get(vm) as? MutableStateFlow<*>
|
||||
if (current is MutableStateFlow<*>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
|
||||
}
|
||||
} catch (_: Throwable) { /* best-effort, continue */ }
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
|
||||
}
|
||||
|
||||
// dump semantics tree for debugging
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
|
||||
} catch (_: Throwable) { }
|
||||
|
||||
// wait for the main title and info rows to appear
|
||||
fun waitForText(text: String, timeoutMs: Long = 20000L) {
|
||||
composeTestRule.waitUntil(timeoutMs) {
|
||||
try {
|
||||
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (_: AssertionError) { false }
|
||||
}
|
||||
}
|
||||
|
||||
waitForText("Startseite")
|
||||
waitForText("Öffentliche")
|
||||
|
||||
// basic assertions (use substring matching)
|
||||
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsUiAutomatorClickTest {
|
||||
|
||||
@Test
|
||||
fun clickThroughExistingCmsPages_andTrySave() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val device = UiDevice.getInstance(instrumentation)
|
||||
val packageName = "de.harheimertc.local"
|
||||
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||
?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
|
||||
context.startActivity(launchIntent)
|
||||
|
||||
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
|
||||
|
||||
clickText(device, "Intern")
|
||||
clickText(device, "CMS")
|
||||
|
||||
openCmsCard(device, "Startseite")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Inhalte")
|
||||
clickIfPresent(device, "Inhalte speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Vereinsmeisterschaften")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Sportbetrieb")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Einstellungen")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Mitgliederverwaltung")
|
||||
clickIfPresent(device, "Freischalten")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Kontaktanfragen")
|
||||
clickIfPresent(device, "Antworten")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Newsletter")
|
||||
clickIfPresent(device, "Newsletter erstellen")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
if (openCmsCardIfAvailable(device, "Benutzer")) {
|
||||
clickIfPresent(device, "Rollen")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
}
|
||||
|
||||
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
|
||||
|
||||
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
|
||||
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
|
||||
}
|
||||
|
||||
private fun openCmsCard(device: UiDevice, label: String) {
|
||||
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
|
||||
clickText(device, label)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
|
||||
if (clickIfPresent(device, label, 1500)) return true
|
||||
return clickTextWithScroll(device, label)
|
||||
}
|
||||
|
||||
private fun backToCmsDashboard(device: UiDevice) {
|
||||
if (!clickIfPresent(device, "CMS", 3000)) {
|
||||
device.pressBack()
|
||||
device.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
|
||||
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||
requireNotNull(obj) { "Text nicht gefunden: $text" }
|
||||
obj.click()
|
||||
device.waitForIdle()
|
||||
return obj
|
||||
}
|
||||
|
||||
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
|
||||
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||
if (obj != null) {
|
||||
obj.click()
|
||||
device.waitForIdle()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
|
||||
if (clickIfPresent(device, text, 1500)) return true
|
||||
repeat(maxSwipes) {
|
||||
device.swipe(
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.8).toInt(),
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.25).toInt(),
|
||||
24,
|
||||
)
|
||||
device.waitForIdle()
|
||||
if (clickIfPresent(device, text, 1200)) return true
|
||||
}
|
||||
repeat(maxSwipes) {
|
||||
device.swipe(
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.25).toInt(),
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.8).toInt(),
|
||||
24,
|
||||
)
|
||||
device.waitForIdle()
|
||||
if (clickIfPresent(device, text, 1200)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GalleryScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun galleryScreen_rendersPlaceholder() {
|
||||
composeTestRule.setContent {
|
||||
GalleryScreen()
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HomeScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun homeScreen_renders() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
HomeScreen(navController = navController, showNavigationHeader = false)
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LoginScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun loginScreen_showsFields() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
LoginScreen(navController = navController, showBackNavigation = false)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("E-Mail-Adresse", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Passwort", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Anmelden", useUnmergedTree = true).assertExists()
|
||||
}
|
||||
}
|
||||
8
android-app/app/src/debug/AndroidManifest.xml
Executable file
8
android-app/app/src/debug/AndroidManifest.xml
Executable file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Disable Sentry automatic initialization in debug/test builds -->
|
||||
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||
<meta-data android:name="io.sentry.dsn" android:value="" />
|
||||
</application>
|
||||
</manifest>
|
||||
38
android-app/app/src/main/AndroidManifest.xml
Executable file
38
android-app/app/src/main/AndroidManifest.xml
Executable file
@@ -0,0 +1,38 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".notifications.HarheimerMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
51
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Executable file
51
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Executable file
@@ -0,0 +1,51 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import io.sentry.Sentry
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
import android.util.Log
|
||||
|
||||
@HiltAndroidApp
|
||||
class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
override fun onCreate() {
|
||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||
super.onCreate()
|
||||
HarheimerNotifications.createChannels(this)
|
||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||
Sentry.init { options ->
|
||||
options.dsn = BuildConfig.SENTRY_DSN
|
||||
options.environment = BuildConfig.ENVIRONMENT_NAME.ifBlank { "production" }
|
||||
options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}"
|
||||
options.isEnableAutoSessionTracking = true
|
||||
options.tracesSampleRate = 0.05
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader =
|
||||
ImageLoader.Builder(this)
|
||||
.okHttpClient(okHttpClient)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.20)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(75L * 1024L * 1024L)
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
124
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Executable file
124
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Executable file
@@ -0,0 +1,124 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.harheimertc.ui.navigation.NavGraph
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject
|
||||
lateinit var authRepository: AuthRepository
|
||||
|
||||
@Inject
|
||||
lateinit var pushTokenRepository: PushTokenRepository
|
||||
|
||||
private val notificationRoute = mutableStateOf<String?>(null)
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermissionIfNeeded()
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
setContent {
|
||||
App(
|
||||
notificationRoute = notificationRoute.value,
|
||||
onNotificationRouteConsumed = { notificationRoute.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
syncPushTokenWhenSignedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side tokens are persistent, but re-registering the current FCM
|
||||
* token is cheap and idempotent. It restores a token if server data was
|
||||
* ever recovered from a backup or manually repaired.
|
||||
*/
|
||||
private fun syncPushTokenWhenSignedIn() {
|
||||
if (authRepository.getToken().isNullOrBlank() && authRepository.getRefreshToken().isNullOrBlank()) return
|
||||
lifecycleScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
|
||||
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
|
||||
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App(
|
||||
notificationRoute: String? = null,
|
||||
onNotificationRouteConsumed: () -> Unit = {},
|
||||
) {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx as? ComponentActivity
|
||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
LaunchedEffect(notificationRoute) {
|
||||
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
popUpTo(Destinations.Home.route)
|
||||
}
|
||||
onNotificationRouteConsumed()
|
||||
}
|
||||
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewMain() {
|
||||
App()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccessTokenAuthenticator @Inject constructor(
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) : Authenticator {
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
|
||||
return null
|
||||
}
|
||||
|
||||
val currentAccessToken = response.request.header("Authorization")
|
||||
?.removePrefix("Bearer ")
|
||||
?.takeIf(String::isNotBlank)
|
||||
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
|
||||
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $refreshedToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var current: Response? = response
|
||||
var count = 0
|
||||
while (current != null) {
|
||||
count += 1
|
||||
current = current.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
853
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Executable file
853
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Executable file
@@ -0,0 +1,853 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
import retrofit2.http.Streaming
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.RequestBody
|
||||
|
||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
val uhrzeit: String? = null,
|
||||
val titel: String = "",
|
||||
val beschreibung: String? = null,
|
||||
val kategorie: String? = null,
|
||||
)
|
||||
data class SpielplanResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val data: List<SpielDto> = emptyList(),
|
||||
val headers: List<String> = emptyList(),
|
||||
val season: String? = null,
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class LiveTickerResponse(
|
||||
val matches: List<LiveTickerMatchDto> = emptyList(),
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
data class LiveTickerMatchDto(
|
||||
val meetingId: String = "",
|
||||
val homeTeam: String = "",
|
||||
val awayTeam: String = "",
|
||||
val score: String = "",
|
||||
val startsAt: Long = 0,
|
||||
val hasLiveData: Boolean = false,
|
||||
val completed: Boolean = false,
|
||||
val completedAt: Long? = null,
|
||||
val updatedAt: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class MannschaftenSeasonsResponse(
|
||||
val success: Boolean = false,
|
||||
val seasons: List<String> = emptyList(),
|
||||
val currentSeason: String = "",
|
||||
val defaultSeason: String = "",
|
||||
)
|
||||
data class SpielDto(
|
||||
@param:Json(name = "Termin") val termin: String = "",
|
||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||
@param:Json(name = "GastMannschaft") val gastMannschaft: String = "",
|
||||
@param:Json(name = "HeimMannschaftAltersklasse") val heimAltersklasse: String = "",
|
||||
@param:Json(name = "GastMannschaftAltersklasse") val gastAltersklasse: String = "",
|
||||
@param:Json(name = "Altersklasse") val altersklasse: String = "",
|
||||
@param:Json(name = "Liga") val liga: String = "",
|
||||
@param:Json(name = "Staffel") val staffel: String = "",
|
||||
@param:Json(name = "Runde") val runde: String? = null,
|
||||
@param:Json(name = "SpieleHeim") val spieleHeim: String = "",
|
||||
@param:Json(name = "SpieleGast") val spieleGast: String = "",
|
||||
)
|
||||
data class TeamTableResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val season: String? = null,
|
||||
val table: TeamTableDto? = null,
|
||||
)
|
||||
data class TeamTableDto(
|
||||
val teamName: String = "",
|
||||
val leagueName: String = "",
|
||||
val table: LeagueTableDto? = null,
|
||||
)
|
||||
data class LeagueTableDto(
|
||||
val leagueTable: List<LeagueTableRowDto> = emptyList(),
|
||||
)
|
||||
data class LeagueTableRowDto(
|
||||
@param:Json(name = "table_rank") val rank: Int? = null,
|
||||
@param:Json(name = "team_name") val teamName: String = "",
|
||||
@param:Json(name = "meetings_count") val meetings: Int? = null,
|
||||
@param:Json(name = "meetings_won") val won: Int? = null,
|
||||
@param:Json(name = "meetings_tie") val tied: Int? = null,
|
||||
@param:Json(name = "meetings_lost") val lost: Int? = null,
|
||||
@param:Json(name = "sets_won") val setsWon: Int? = null,
|
||||
@param:Json(name = "sets_lost") val setsLost: Int? = null,
|
||||
@param:Json(name = "games_won") val gamesWon: Int? = null,
|
||||
@param:Json(name = "games_lost") val gamesLost: Int? = null,
|
||||
@param:Json(name = "points_won") val pointsWon: Int? = null,
|
||||
@param:Json(name = "points_lost") val pointsLost: Int? = null,
|
||||
@param:Json(name = "rise_fall_state") val movement: String? = null,
|
||||
)
|
||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||
data class NewsDto(
|
||||
val id: String? = null,
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val created: String? = null,
|
||||
val updated: String? = null,
|
||||
val author: String? = null,
|
||||
val isPublic: Boolean = false,
|
||||
val isHidden: Boolean = false,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
data class NewsResponse(
|
||||
val success: Boolean = false,
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
)
|
||||
data class NewsSaveRequest(
|
||||
val id: String? = null,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isPublic: Boolean = false,
|
||||
val isHidden: Boolean = false,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
data class PublicGalleryImageDto(
|
||||
val filename: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
data class GalleryImageDto(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val isPublic: Boolean = false,
|
||||
val uploadedAt: String? = null,
|
||||
val previewFilename: String? = null,
|
||||
)
|
||||
data class GalleryPaginationDto(
|
||||
val page: Int = 1,
|
||||
val perPage: Int = 10,
|
||||
val total: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
)
|
||||
data class GalleryListResponse(
|
||||
val success: Boolean = false,
|
||||
val images: List<GalleryImageDto> = emptyList(),
|
||||
val pagination: GalleryPaginationDto = GalleryPaginationDto(),
|
||||
)
|
||||
data class GalleryUploadImageDto(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val isPublic: Boolean = false,
|
||||
)
|
||||
data class GalleryUploadResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val image: GalleryUploadImageDto? = null,
|
||||
)
|
||||
data class MembershipRequest(
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val strasse: String,
|
||||
val plz: String,
|
||||
val ort: String,
|
||||
val geburtsdatum: String,
|
||||
val email: String,
|
||||
val telefon_privat: String? = null,
|
||||
val telefon_mobil: String? = null,
|
||||
val mitgliedschaftsart: String,
|
||||
val lastschrift_erlaubt: Boolean,
|
||||
val kontoinhaber: String,
|
||||
val iban: String,
|
||||
val bic: String? = null,
|
||||
val bank: String? = null,
|
||||
val datenschutz_einverstanden: Boolean,
|
||||
val satzung_anerkannt: Boolean,
|
||||
)
|
||||
data class MembershipResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
val downloadToken: String? = null,
|
||||
)
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val client: String = "android",
|
||||
val deviceName: String = "Harheimer TC Android-App",
|
||||
)
|
||||
data class AuthUserDto(
|
||||
val id: String? = null,
|
||||
val email: String = "",
|
||||
val name: String? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
)
|
||||
data class LoginResponse(
|
||||
val success: Boolean = false,
|
||||
val token: String? = null,
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val user: AuthUserDto? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
data class RefreshRequest(val refreshToken: String)
|
||||
data class LogoutRequest(val refreshToken: String? = null)
|
||||
data class AuthStatusResponse(
|
||||
val isLoggedIn: Boolean = false,
|
||||
val user: AuthUserDto? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
)
|
||||
data class ResetPasswordRequest(val email: String)
|
||||
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
||||
data class SaveCsvRequest(
|
||||
val filename: String,
|
||||
val content: String,
|
||||
)
|
||||
data class SaveCsvResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val writtenTo: List<String> = emptyList(),
|
||||
val jsonWrittenTo: List<String> = emptyList(),
|
||||
)
|
||||
data class PasskeyAuthenticationOptionsRequest(
|
||||
val email: String? = null,
|
||||
val client: String = "android",
|
||||
)
|
||||
data class PasskeyRegistrationOptionsRequest(
|
||||
val preferredAuthenticatorType: String? = null,
|
||||
val client: String = "android",
|
||||
)
|
||||
data class PasskeyDto(
|
||||
val id: String = "",
|
||||
val credentialId: String = "",
|
||||
val createdAt: String? = null,
|
||||
val lastUsedAt: String? = null,
|
||||
val name: String = "",
|
||||
)
|
||||
data class PasskeysResponse(
|
||||
val success: Boolean = false,
|
||||
val passkeys: List<PasskeyDto> = emptyList(),
|
||||
)
|
||||
data class RemovePasskeyRequest(val credentialId: String)
|
||||
data class ProfileVisibilityDto(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
data class ProfileUserDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String = "",
|
||||
val geburtsdatum: String = "",
|
||||
val visibility: ProfileVisibilityDto = ProfileVisibilityDto(),
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
)
|
||||
data class ProfileResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val user: ProfileUserDto? = null,
|
||||
)
|
||||
data class ProfileUpdateRequest(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
val geburtsdatum: String? = null,
|
||||
val visibility: ProfileVisibilityDto,
|
||||
val currentPassword: String? = null,
|
||||
val newPassword: String? = null,
|
||||
)
|
||||
data class NotificationSettingsDto(
|
||||
val newNews: Boolean = false,
|
||||
val newEvents: Boolean = false,
|
||||
val eventsToday: Boolean = false,
|
||||
val eventsTomorrow: Boolean = false,
|
||||
val ownTeamMatches: Boolean = false,
|
||||
val allTeamMatches: Boolean = false,
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: List<String> = emptyList(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = "09:00",
|
||||
)
|
||||
data class NotificationSettingsResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val settings: NotificationSettingsDto = NotificationSettingsDto(),
|
||||
)
|
||||
data class PushTokenRequest(
|
||||
val token: String,
|
||||
val platform: String = "android",
|
||||
val appVersion: String? = null,
|
||||
val installationId: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
val dayMonth: String = "",
|
||||
val inDays: Int = 0,
|
||||
)
|
||||
data class BirthdaysResponse(
|
||||
val success: Boolean = false,
|
||||
val birthdays: List<BirthdayDto> = emptyList(),
|
||||
)
|
||||
data class QttrSourceDto(
|
||||
val url: String = "",
|
||||
)
|
||||
data class QttrRowDto(
|
||||
val rank: Int? = null,
|
||||
val playerNumber: Int? = null,
|
||||
val gender: String? = null,
|
||||
val playerName: String = "",
|
||||
val clubName: String = "",
|
||||
val currentQttr: Int? = null,
|
||||
val currentTtr: Int? = null,
|
||||
val previousQttr: Int? = null,
|
||||
val birthdate: String? = null,
|
||||
)
|
||||
data class QttrValuesResponse(
|
||||
val format: String = "",
|
||||
val importedAt: String = "",
|
||||
val source: QttrSourceDto = QttrSourceDto(),
|
||||
val title: String? = null,
|
||||
val headerCount: Int = 0,
|
||||
val rowCount: Int = 0,
|
||||
val rows: List<QttrRowDto> = emptyList(),
|
||||
)
|
||||
data class MemberDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val address: String? = null,
|
||||
val birthday: String? = null,
|
||||
val geburtsdatum: String? = null,
|
||||
val source: String = "",
|
||||
val notes: String = "",
|
||||
val hasLogin: Boolean = false,
|
||||
val editable: Boolean = false,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
val loginRoles: List<String> = emptyList(),
|
||||
)
|
||||
data class MembersResponse(
|
||||
val success: Boolean = false,
|
||||
val members: List<MemberDto> = emptyList(),
|
||||
)
|
||||
data class RegistrationVisibility(val showBirthday: Boolean)
|
||||
data class RegistrationRequest(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
val password: String,
|
||||
val geburtsdatum: String,
|
||||
val visibility: RegistrationVisibility,
|
||||
)
|
||||
data class TrainingLocationDto(
|
||||
val name: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
)
|
||||
data class TrainingTimeDto(
|
||||
val id: String = "",
|
||||
val tag: String = "",
|
||||
val von: String = "",
|
||||
val bis: String = "",
|
||||
val gruppe: String = "",
|
||||
val info: String? = null,
|
||||
)
|
||||
data class TrainingDto(
|
||||
val ort: TrainingLocationDto = TrainingLocationDto(),
|
||||
val zeiten: List<TrainingTimeDto> = emptyList(),
|
||||
)
|
||||
data class TrainerDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val lizenz: String = "",
|
||||
val schwerpunkt: String = "",
|
||||
val zusatz: String? = null,
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class BoardMemberDto(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val telefon: String = "",
|
||||
val email: String = "",
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class VorstandDto(
|
||||
val vorsitzender: BoardMemberDto = BoardMemberDto(),
|
||||
val stellvertreter: BoardMemberDto = BoardMemberDto(),
|
||||
val kassenwart: BoardMemberDto = BoardMemberDto(),
|
||||
val schriftfuehrer: BoardMemberDto = BoardMemberDto(),
|
||||
val sportwart: BoardMemberDto = BoardMemberDto(),
|
||||
val jugendwart: BoardMemberDto = BoardMemberDto(),
|
||||
)
|
||||
data class VereinDto(
|
||||
val name: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val useVorsitzenderAddress: Boolean = false,
|
||||
)
|
||||
data class WebsiteResponsibleDto(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val email: String = "",
|
||||
)
|
||||
data class WebsiteDto(
|
||||
val verantwortlicher: WebsiteResponsibleDto = WebsiteResponsibleDto(),
|
||||
)
|
||||
data class SatzungDto(
|
||||
val pdfUrl: String = "",
|
||||
val content: String = "",
|
||||
)
|
||||
data class MembershipTierDto(
|
||||
val id: String = "",
|
||||
val typ: String = "",
|
||||
val beschreibung: String? = null,
|
||||
val preis: Int = 0,
|
||||
val features: List<String> = emptyList(),
|
||||
)
|
||||
data class LinkItemDto(
|
||||
val label: String = "",
|
||||
val href: String = "",
|
||||
val description: String = "",
|
||||
val id: String = "",
|
||||
)
|
||||
data class LinkSectionDto(
|
||||
val title: String = "",
|
||||
val items: List<LinkItemDto> = emptyList(),
|
||||
val id: String = "",
|
||||
)
|
||||
data class HomepageSectionDto(
|
||||
val id: String = "",
|
||||
val enabled: Boolean = true,
|
||||
val key: String? = null,
|
||||
val marker: String? = null,
|
||||
val config: HomepageSectionConfigDto? = null,
|
||||
)
|
||||
data class HomepageSectionConfigDto(
|
||||
val season: String? = null,
|
||||
val teamName: String? = null,
|
||||
val teamAgeGroup: String? = null,
|
||||
)
|
||||
data class HomepageDto(
|
||||
val sections: List<HomepageSectionDto> = emptyList(),
|
||||
)
|
||||
data class HeroImageVariantDto(
|
||||
val key: String = "",
|
||||
val mobileWebp: String = "",
|
||||
val desktopWebp: String = "",
|
||||
val fallback: String = "",
|
||||
)
|
||||
data class HeroImagesResponse(
|
||||
val variants: List<HeroImageVariantDto> = emptyList(),
|
||||
)
|
||||
data class SeitenDto(
|
||||
val ueberUns: String = "",
|
||||
val geschichte: String = "",
|
||||
val ttRegeln: String = "",
|
||||
val satzung: SatzungDto = SatzungDto(),
|
||||
val links: String = "",
|
||||
val linksStructured: List<LinkSectionDto> = emptyList(),
|
||||
)
|
||||
data class ConfigResponse(
|
||||
val training: TrainingDto = TrainingDto(),
|
||||
val trainer: List<TrainerDto> = emptyList(),
|
||||
val mitgliedschaft: List<MembershipTierDto> = emptyList(),
|
||||
val verein: VereinDto = VereinDto(),
|
||||
val vorstand: VorstandDto = VorstandDto(),
|
||||
val website: WebsiteDto = WebsiteDto(),
|
||||
val seiten: SeitenDto = SeitenDto(),
|
||||
val homepage: HomepageDto = HomepageDto(),
|
||||
)
|
||||
data class CmsUserDto(
|
||||
val id: String = "",
|
||||
val email: String? = null,
|
||||
val name: String = "",
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
val phone: String = "",
|
||||
val active: Boolean = true,
|
||||
val created: String? = null,
|
||||
val lastLogin: String? = null,
|
||||
)
|
||||
data class CmsUsersResponse(val users: List<CmsUserDto> = emptyList())
|
||||
data class ContactRequestDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String? = null,
|
||||
val message: String = "",
|
||||
val status: String = "",
|
||||
val createdAt: String? = null,
|
||||
val repliedAt: String? = null,
|
||||
)
|
||||
data class NewsletterDto(
|
||||
val id: String = "",
|
||||
val subject: String = "",
|
||||
val title: String = "",
|
||||
val createdAt: String? = null,
|
||||
val sentAt: String? = null,
|
||||
val status: String? = null,
|
||||
)
|
||||
data class NewsletterListResponse(
|
||||
val success: Boolean = false,
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterCreateRequest(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val type: String,
|
||||
val targetGroup: String? = null,
|
||||
val sendToExternal: Boolean? = null,
|
||||
)
|
||||
|
||||
data class NewsletterCreateResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val newsletter: NewsletterDto? = null,
|
||||
)
|
||||
|
||||
data class NewsletterSendResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val stats: Map<String, Any>? = null,
|
||||
)
|
||||
data class NewsletterGroupDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val subscribers: List<String> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
data class NewsletterGroupsResponse(
|
||||
val success: Boolean = false,
|
||||
val groups: List<NewsletterGroupDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterSubscriptionRequest(
|
||||
val groupId: String,
|
||||
val email: String,
|
||||
val name: String? = null,
|
||||
)
|
||||
data class PasswordResetStepDto(
|
||||
val ts: String? = null,
|
||||
val step: String = "",
|
||||
val status: String = "",
|
||||
val reason: String? = null,
|
||||
val errorCode: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
data class PasswordResetAttemptDto(
|
||||
val requestId: String = "",
|
||||
val startedAt: String? = null,
|
||||
val emailMasked: String? = null,
|
||||
val ip: String? = null,
|
||||
val failed: Boolean = false,
|
||||
val steps: List<PasswordResetStepDto> = emptyList(),
|
||||
)
|
||||
data class PasswordResetMatchingUserDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val active: Boolean = true,
|
||||
val lastLogin: String? = null,
|
||||
)
|
||||
data class PasswordResetDiagnosticsResponse(
|
||||
val retentionHours: Int = 0,
|
||||
val searchedEmail: String? = null,
|
||||
val matchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
|
||||
val attempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
interface ApiService {
|
||||
@POST("/api/contact")
|
||||
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
|
||||
|
||||
@GET("/api/galerie/list")
|
||||
suspend fun galerieList(
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("perPage") perPage: Int = 60,
|
||||
): Response<GalleryListResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("/api/galerie/upload")
|
||||
suspend fun uploadGalleryImage(
|
||||
@Part image: MultipartBody.Part,
|
||||
@Part("title") title: RequestBody,
|
||||
@Part("description") description: RequestBody,
|
||||
@Part("isPublic") isPublic: RequestBody,
|
||||
): Response<GalleryUploadResponse>
|
||||
|
||||
@GET("/api/galerie")
|
||||
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
|
||||
|
||||
@GET("/api/termine")
|
||||
suspend fun termine(): Response<TermineResponse>
|
||||
|
||||
@GET("/api/termine-manage")
|
||||
suspend fun termineManage(): Response<TermineManageResponse>
|
||||
|
||||
@POST("/api/termine-manage")
|
||||
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/termine-manage")
|
||||
suspend fun deleteTermin(
|
||||
@Query("datum") datum: String,
|
||||
@Query("uhrzeit") uhrzeit: String = "",
|
||||
@Query("titel") titel: String,
|
||||
@Query("beschreibung") beschreibung: String = "",
|
||||
@Query("kategorie") kategorie: String = "Sonstiges",
|
||||
): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/spielplan")
|
||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||
|
||||
@GET("/api/spielplan/table")
|
||||
suspend fun spielplanTable(
|
||||
@Query("team") team: String,
|
||||
@Query("season") season: String? = null,
|
||||
): Response<TeamTableResponse>
|
||||
|
||||
@GET("/api/news-public")
|
||||
suspend fun publicNews(): Response<NewsPublicResponse>
|
||||
|
||||
@GET("/api/news")
|
||||
suspend fun memberNews(): Response<NewsResponse>
|
||||
|
||||
@POST("/api/news")
|
||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/news")
|
||||
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@GET("/api/mannschaften/seasons")
|
||||
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@GET("/api/hero-images")
|
||||
suspend fun heroImages(): Response<HeroImagesResponse>
|
||||
|
||||
@PUT("/api/config")
|
||||
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||
|
||||
@GET("/data/spielsysteme.csv")
|
||||
suspend fun spielsysteme(): Response<ResponseBody>
|
||||
|
||||
@GET("/api/vereinsmeisterschaften")
|
||||
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
|
||||
|
||||
@POST("/api/cms/save-csv")
|
||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||
|
||||
@POST("/api/cms/import-spielplan")
|
||||
suspend fun importSpielplan(): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/membership/generate-pdf")
|
||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun downloadMembershipPdf(
|
||||
@Url downloadUrl: String,
|
||||
@retrofit2.http.Header("X-Membership-Download-Token") downloadToken: String? = null,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("/api/auth/logout")
|
||||
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
|
||||
|
||||
@POST("/api/auth/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/status")
|
||||
suspend fun authStatus(): Response<AuthStatusResponse>
|
||||
|
||||
@POST("/api/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/register")
|
||||
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/authentication-options")
|
||||
suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/passkeys/login")
|
||||
suspend fun passkeyLogin(@Body request: RequestBody): Response<LoginResponse>
|
||||
|
||||
@GET("/api/auth/passkeys/list")
|
||||
suspend fun passkeys(): Response<PasskeysResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/registration-options")
|
||||
suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/passkeys/register")
|
||||
suspend fun registerPasskey(@Body request: RequestBody): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/passkeys/remove")
|
||||
suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/profile")
|
||||
suspend fun profile(): Response<ProfileResponse>
|
||||
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||
|
||||
@GET("/api/profile/notifications")
|
||||
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
|
||||
|
||||
@retrofit2.http.PUT("/api/profile/notifications")
|
||||
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
|
||||
|
||||
@POST("/api/profile/push-token")
|
||||
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/birthdays")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
@GET("/api/mitgliederbereich/qttr")
|
||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||
|
||||
@GET("/api/mitgliederbereich/live-ticker")
|
||||
suspend fun liveTicker(): Response<LiveTickerResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
data class MemberSaveRequest(
|
||||
val id: String? = null,
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val geburtsdatum: String,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val address: String? = null,
|
||||
val notes: String? = null,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
|
||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||
data class BulkImportResponse(val success: Boolean = false, val summary: Map<String, Int>? = null)
|
||||
|
||||
@POST("/api/members")
|
||||
suspend fun saveMember(@Body request: MemberSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/members")
|
||||
suspend fun deleteMember(@Body body: Map<String, String>): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/members/bulk")
|
||||
suspend fun bulkImportMembers(@Body request: BulkImportRequest): Response<BulkImportResponse>
|
||||
|
||||
@POST("/api/members/toggle-mannschaftsspieler")
|
||||
suspend fun toggleMannschaftsspieler(@Body body: Map<String, String>): Response<Map<String, Any>>
|
||||
|
||||
@GET("/api/cms/users/list")
|
||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||
|
||||
data class UpdateUserRolesRequest(val id: String, val roles: List<String>)
|
||||
data class UpdateUserActiveRequest(val id: String, val active: Boolean)
|
||||
|
||||
@PUT("/api/cms/users/update-roles")
|
||||
suspend fun updateUserRoles(@Body request: UpdateUserRolesRequest): Response<AuthMessageResponse>
|
||||
|
||||
@PUT("/api/cms/users/update-active")
|
||||
suspend fun updateUserActive(@Body request: UpdateUserActiveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/cms/users/resend-invite")
|
||||
suspend fun resendInvite(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/contact-requests")
|
||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||
|
||||
data class ContactReplyRequest(val message: String)
|
||||
|
||||
@POST("/api/cms/contact-requests/{id}/reply")
|
||||
suspend fun replyToContactRequest(@Path("id") id: String, @Body request: ContactReplyRequest): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@PATCH("/api/cms/contact-requests/{id}/toggle-status")
|
||||
suspend fun toggleContactRequestStatus(@Path("id") id: String): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@GET("/api/newsletter/list")
|
||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/list")
|
||||
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@POST("/api/newsletter/groups/create")
|
||||
suspend fun createNewsletterGroup(@Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||
|
||||
@PUT("/api/newsletter/groups/{id}")
|
||||
suspend fun updateNewsletterGroup(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/newsletter/groups/{id}")
|
||||
suspend fun deleteNewsletterGroup(@Path("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/newsletter/create")
|
||||
suspend fun createNewsletter(@Body request: NewsletterCreateRequest): Response<NewsletterCreateResponse>
|
||||
|
||||
@PUT("/api/newsletter/{id}")
|
||||
suspend fun updateNewsletter(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<NewsletterCreateResponse>
|
||||
|
||||
@POST("/api/newsletter/{id}/send")
|
||||
suspend fun sendNewsletter(@Path("id") id: String): Response<NewsletterSendResponse>
|
||||
|
||||
@DELETE("/api/newsletter/{id}")
|
||||
suspend fun deleteNewsletter(@Path("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/public-list")
|
||||
suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@POST("/api/newsletter/subscribe")
|
||||
suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/newsletter/unsubscribe-by-email")
|
||||
suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/newsletter/confirm")
|
||||
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/password-reset-diagnostics")
|
||||
suspend fun passwordResetDiagnostics(
|
||||
@Query("email") email: String? = null,
|
||||
@Query("failedOnly") failedOnly: Boolean = true,
|
||||
): Response<PasswordResetDiagnosticsResponse>
|
||||
}
|
||||
17
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Executable file
17
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Executable file
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthInterceptor @Inject constructor(private val authRepository: AuthRepository) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
val token = authRepository.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
7
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Executable file
7
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Executable file
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
|
||||
object MediaTypes {
|
||||
val json = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
106
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Executable file
106
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Executable file
@@ -0,0 +1,106 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import de.harheimertc.BuildConfig
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.Cache
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import javax.inject.Singleton
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHttpCache(@ApplicationContext context: Context): Cache =
|
||||
Cache(context.cacheDir.resolve("http_cache"), 25L * 1024L * 1024L)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
@ApplicationContext context: Context,
|
||||
authInterceptor: AuthInterceptor,
|
||||
accessTokenAuthenticator: AccessTokenAuthenticator,
|
||||
cache: Cache,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
|
||||
val cookies = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.cache(cache)
|
||||
.cookieJar(JavaNetCookieJar(cookies))
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (request.method == "GET" && !hasNetwork(context)) {
|
||||
val offlineRequest = request.newBuilder()
|
||||
.cacheControl(CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build())
|
||||
.build()
|
||||
chain.proceed(offlineRequest)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
.addNetworkInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
val request = response.request
|
||||
if (request.method == "GET" && request.header("Authorization").isNullOrBlank()) {
|
||||
response.newBuilder()
|
||||
.header("Cache-Control", "public, max-age=300")
|
||||
.build()
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
.authenticator(accessTokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
||||
val runtimeBase = BuildConfig.API_BASE_URL
|
||||
android.util.Log.i("NetworkModule", "Retrofit baseUrl runtime=$runtimeBase")
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(runtimeBase)
|
||||
.client(client)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
||||
|
||||
private fun hasNetwork(context: Context): Boolean {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||
val network = manager.activeNetwork ?: return false
|
||||
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
172
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Executable file
172
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Executable file
@@ -0,0 +1,172 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SecureOfflineCache @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val tag = "SecureOfflineCache"
|
||||
private companion object {
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_QTTR_VALUES = "qttr_values"
|
||||
const val KEY_MEMBERS = "members"
|
||||
const val KEY_MEMBER_NEWS = "member_news"
|
||||
const val KEY_CMS_CONFIG = "cms_config"
|
||||
const val KEY_CMS_USERS = "cms_users"
|
||||
const val KEY_CONTACT_REQUESTS = "contact_requests"
|
||||
const val KEY_NEWSLETTERS = "newsletters"
|
||||
const val KEY_NEWSLETTER_GROUPS = "newsletter_groups"
|
||||
const val KEY_PASSWORD_RESET_DIAGNOSTICS = "password_reset_diagnostics"
|
||||
const val TIMESTAMP_SUFFIX = "_ts"
|
||||
}
|
||||
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_offline_cache",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_offline_cache")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_offline_cache",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
|
||||
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putNews(response: NewsResponse) = put(KEY_MEMBER_NEWS, response, NewsResponse::class.java)
|
||||
fun getNews(maxAgeMillis: Long? = null): NewsResponse? = get(KEY_MEMBER_NEWS, NewsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putConfig(response: ConfigResponse) = put(KEY_CMS_CONFIG, response, ConfigResponse::class.java)
|
||||
fun getConfig(maxAgeMillis: Long? = null): ConfigResponse? = get(KEY_CMS_CONFIG, ConfigResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putCmsUsers(response: CmsUsersResponse) = put(KEY_CMS_USERS, response, CmsUsersResponse::class.java)
|
||||
fun getCmsUsers(maxAgeMillis: Long? = null): CmsUsersResponse? = get(KEY_CMS_USERS, CmsUsersResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putContactRequests(response: List<ContactRequestDto>) {
|
||||
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
|
||||
preferences.edit()
|
||||
.putString(KEY_CONTACT_REQUESTS, json)
|
||||
.putLong(timestampKey(KEY_CONTACT_REQUESTS), System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getContactRequests(maxAgeMillis: Long? = null): List<ContactRequestDto>? {
|
||||
if (isExpired(KEY_CONTACT_REQUESTS, maxAgeMillis)) return null
|
||||
val json = preferences.getString(KEY_CONTACT_REQUESTS, null) ?: return null
|
||||
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
fun putNewsletters(response: NewsletterListResponse) = put(KEY_NEWSLETTERS, response, NewsletterListResponse::class.java)
|
||||
fun getNewsletters(maxAgeMillis: Long? = null): NewsletterListResponse? =
|
||||
get(KEY_NEWSLETTERS, NewsletterListResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put(KEY_NEWSLETTER_GROUPS, response, NewsletterGroupsResponse::class.java)
|
||||
fun getNewsletterGroups(maxAgeMillis: Long? = null): NewsletterGroupsResponse? =
|
||||
get(KEY_NEWSLETTER_GROUPS, NewsletterGroupsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
|
||||
put(KEY_PASSWORD_RESET_DIAGNOSTICS, response, PasswordResetDiagnosticsResponse::class.java)
|
||||
fun getPasswordResetDiagnostics(maxAgeMillis: Long? = null): PasswordResetDiagnosticsResponse? =
|
||||
get(KEY_PASSWORD_RESET_DIAGNOSTICS, PasswordResetDiagnosticsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun clearCmsProtectedCaches() {
|
||||
clear(
|
||||
KEY_CMS_CONFIG,
|
||||
KEY_CMS_USERS,
|
||||
KEY_CONTACT_REQUESTS,
|
||||
KEY_NEWSLETTERS,
|
||||
KEY_NEWSLETTER_GROUPS,
|
||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||
KEY_MEMBER_NEWS,
|
||||
KEY_QTTR_VALUES,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCmsUsersCache() = clear(KEY_CMS_USERS)
|
||||
fun clearContactRequestsCache() = clear(KEY_CONTACT_REQUESTS)
|
||||
fun clearNewslettersCache() = clear(KEY_NEWSLETTERS)
|
||||
fun clearNewsletterGroupsCache() = clear(KEY_NEWSLETTER_GROUPS)
|
||||
fun clearPasswordResetDiagnosticsCache() = clear(KEY_PASSWORD_RESET_DIAGNOSTICS)
|
||||
fun clearCmsConfigCache() = clear(KEY_CMS_CONFIG)
|
||||
fun clearCmsNewsCache() = clear(KEY_MEMBER_NEWS)
|
||||
|
||||
private fun <T> put(key: String, value: T, type: Class<T>) {
|
||||
val json = moshi.adapter(type).toJson(value)
|
||||
preferences.edit()
|
||||
.putString(key, json)
|
||||
.putLong(timestampKey(key), System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun <T> get(key: String, type: Class<T>, maxAgeMillis: Long? = null): T? {
|
||||
if (isExpired(key, maxAgeMillis)) return null
|
||||
val json = preferences.getString(key, null) ?: return null
|
||||
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun clear(vararg keys: String) {
|
||||
val editor = preferences.edit()
|
||||
keys.forEach { key ->
|
||||
editor.remove(key)
|
||||
editor.remove(timestampKey(key))
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
private fun isExpired(key: String, maxAgeMillis: Long?): Boolean {
|
||||
if (maxAgeMillis == null) return false
|
||||
val savedAt = preferences.getLong(timestampKey(key), 0L)
|
||||
if (savedAt <= 0L) return true
|
||||
return (System.currentTimeMillis() - savedAt) > maxAgeMillis
|
||||
}
|
||||
|
||||
private fun timestampKey(key: String): String = key + TIMESTAMP_SUFFIX
|
||||
}
|
||||
64
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Executable file
64
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Executable file
@@ -0,0 +1,64 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SessionRefresher @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
moshi: Moshi,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val client = OkHttpClient.Builder().build()
|
||||
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
|
||||
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
|
||||
|
||||
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
|
||||
refreshAccessTokenBlocking() != null
|
||||
}
|
||||
|
||||
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
|
||||
val currentToken = authRepository.getToken()
|
||||
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
|
||||
return@synchronized currentToken
|
||||
}
|
||||
|
||||
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
|
||||
val request = Request.Builder()
|
||||
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
|
||||
.post(payload.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
authRepository.clearSession()
|
||||
return@synchronized null
|
||||
}
|
||||
if (!response.isSuccessful) return@synchronized null
|
||||
|
||||
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
|
||||
?: return@synchronized null
|
||||
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized null
|
||||
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
|
||||
accessToken
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
17
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Executable file
17
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Executable file
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.AuthRepositoryImpl
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.harheimertc.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class HarheimerMessagingService : FirebaseMessagingService() {
|
||||
@Inject
|
||||
lateinit var pushTokenRepository: PushTokenRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
serviceScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
val title = message.notification?.title
|
||||
?: message.data["title"]
|
||||
?: "Harheimer TC"
|
||||
val body = message.notification?.body
|
||||
?: message.data["body"]
|
||||
?: message.data["message"]
|
||||
?: return
|
||||
val notificationId = message.data["notificationId"]?.toIntOrNull()
|
||||
?: message.messageId?.hashCode()
|
||||
?: System.currentTimeMillis().toInt()
|
||||
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body, message.data)
|
||||
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.harheimertc.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.harheimertc.MainActivity
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
|
||||
object HarheimerNotifications {
|
||||
// A new ID is intentional: Android does not allow an app update to raise
|
||||
// the importance of an already-created notification channel.
|
||||
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates_v2"
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
DEFAULT_CHANNEL_ID,
|
||||
"Harheimer TC",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Benachrichtigungen des Harheimer TC"
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun hasNotificationPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun showBasicNotification(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
title: String,
|
||||
message: String,
|
||||
data: Map<String, String> = emptyMap(),
|
||||
): Boolean {
|
||||
if (!hasNotificationPermission(context)) return false
|
||||
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
return try {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
true
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||
val route = destinationRoute(payload)
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
|
||||
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
|
||||
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||
"team_matches" -> Destinations.Spielplan.route
|
||||
"live_ticker_started", "live_ticker_score_changed", "live_ticker_finished" -> Destinations.MemberArea.route
|
||||
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
|
||||
"birthdays" -> Destinations.MemberArea.route
|
||||
"contact_request" -> Destinations.CmsContactRequests.route
|
||||
"user_registration" -> Destinations.CmsBenutzer.route
|
||||
else -> Destinations.Home.route
|
||||
}
|
||||
}
|
||||
13
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Executable file
13
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Executable file
@@ -0,0 +1,13 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
interface AuthRepository {
|
||||
fun getToken(): String?
|
||||
fun getRefreshToken(): String?
|
||||
fun getSessionId(): String?
|
||||
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
|
||||
fun clearSession()
|
||||
// Device binding via Android Keystore (optional enhancement)
|
||||
fun ensureDeviceKey(): String?
|
||||
fun getDevicePublicKey(): String?
|
||||
fun signWithDeviceKey(data: ByteArray): ByteArray?
|
||||
}
|
||||
101
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Executable file
101
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Executable file
@@ -0,0 +1,101 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.security.DeviceKeyManager
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val deviceKeyManager: DeviceKeyManager,
|
||||
) : AuthRepository {
|
||||
private val tag = "AuthRepository"
|
||||
private val tokenKey = "auth_token"
|
||||
private val refreshTokenKey = "auth_refresh_token"
|
||||
private val sessionIdKey = "auth_session_id"
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_auth",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_auth")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_auth",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||
|
||||
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
|
||||
|
||||
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
|
||||
|
||||
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||
preferences.edit().apply {
|
||||
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
|
||||
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
|
||||
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
preferences.edit()
|
||||
.remove(tokenKey)
|
||||
.remove(refreshTokenKey)
|
||||
.remove(sessionIdKey)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// Keystore / device binding helpers
|
||||
override fun ensureDeviceKey(): String? = try {
|
||||
deviceKeyManager.ensureKeyPair()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun getDevicePublicKey(): String? = try {
|
||||
deviceKeyManager.getPublicKeyBase64()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun signWithDeviceKey(data: ByteArray): ByteArray? = try {
|
||||
deviceKeyManager.sign(data)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
463
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Executable file
463
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Executable file
@@ -0,0 +1,463 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.SaveCsvRequest
|
||||
import de.harheimertc.data.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
|
||||
class CmsRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val cache: SecureOfflineCache,
|
||||
) {
|
||||
private companion object {
|
||||
const val CMS_CACHE_MAX_AGE_MS = 24L * 60L * 60L * 1000L
|
||||
const val PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS = 6L * 60L * 60L * 1000L
|
||||
}
|
||||
|
||||
suspend fun config(): Result<ConfigResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putConfig,
|
||||
cached = { cache.getConfig(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Konfiguration konnte nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun saveConfig(config: ConfigResponse): Result<ConfigResponse> = runCatching {
|
||||
val response = api.updateConfig(config)
|
||||
if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.")
|
||||
val saved = response.body() ?: error("Leere Antwort vom Server.")
|
||||
cache.putConfig(saved)
|
||||
saved
|
||||
}
|
||||
|
||||
suspend fun vereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>): Result<SaveCsvResponse> = runCatching {
|
||||
val csvHeader = "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2"
|
||||
val csvRows = results.map { result ->
|
||||
listOf(
|
||||
result.year,
|
||||
result.category,
|
||||
result.rank,
|
||||
result.playerOne,
|
||||
result.playerTwo,
|
||||
result.note,
|
||||
result.imageOne,
|
||||
result.imageTwo,
|
||||
).joinToString(",") { value -> "\"${value.replace("\"", "\"\"")}\"" }
|
||||
}
|
||||
val response = api.saveCsv(
|
||||
SaveCsvRequest(
|
||||
filename = "vereinsmeisterschaften.csv",
|
||||
content = listOf(csvHeader).plus(csvRows).joinToString("\n"),
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht gespeichert werden.")
|
||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
|
||||
val response = api.termineManage()
|
||||
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
|
||||
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.saveTermin(request)
|
||||
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteTermin(
|
||||
datum = request.datum,
|
||||
uhrzeit = request.uhrzeit.orEmpty(),
|
||||
titel = request.titel,
|
||||
beschreibung = request.beschreibung.orEmpty(),
|
||||
kategorie = request.kategorie ?: "Sonstiges",
|
||||
)
|
||||
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
|
||||
CmsMannschaftRow(
|
||||
mannschaft = values[0],
|
||||
liga = values[1],
|
||||
staffelleiter = values[2],
|
||||
telefon = values[3],
|
||||
heimspieltag = values[4],
|
||||
spielsystem = values[5],
|
||||
mannschaftsfuehrer = values[6],
|
||||
spieler = values[7],
|
||||
informationenLink = values[8],
|
||||
letzteAktualisierung = values[9],
|
||||
spielplanMannschaftId = values.getOrElse(10) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
|
||||
val response = api.mannschaftenSeasons()
|
||||
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
|
||||
}
|
||||
|
||||
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
|
||||
val response = api.saveCsv(
|
||||
SaveCsvRequest(
|
||||
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
|
||||
content = rows.toMannschaftenCsv(),
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
|
||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.importSpielplan()
|
||||
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
|
||||
val response = api.saveCsv(
|
||||
SaveCsvRequest(
|
||||
filename = "spielplan.csv",
|
||||
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
|
||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun users(): Result<CmsUsersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.cmsUsers()
|
||||
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putCmsUsers,
|
||||
cached = { cache.getCmsUsers(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Benutzer konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun updateUserRoles(id: String, roles: List<String>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val req = de.harheimertc.data.ApiService.UpdateUserRolesRequest(id, roles)
|
||||
val response = api.updateUserRoles(req)
|
||||
if (!response.isSuccessful) error("Benutzerrollen konnten nicht aktualisiert werden.")
|
||||
cache.clearCmsUsersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun updateUserActive(id: String, active: Boolean): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val req = de.harheimertc.data.ApiService.UpdateUserActiveRequest(id, active)
|
||||
val response = api.updateUserActive(req)
|
||||
if (!response.isSuccessful) error("Benutzerstatus konnte nicht aktualisiert werden.")
|
||||
cache.clearCmsUsersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun resendInvite(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.resendInvite(id)
|
||||
if (!response.isSuccessful) error("Einladung konnte nicht erneut gesendet werden.")
|
||||
cache.clearCmsUsersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.contactRequests()
|
||||
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
||||
response.body() ?: emptyList()
|
||||
},
|
||||
save = cache::putContactRequests,
|
||||
cached = { cache.getContactRequests(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun replyToContactRequest(id: String, message: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||
val req = ApiService.ContactReplyRequest(message)
|
||||
val response = api.replyToContactRequest(id, req)
|
||||
if (!response.isSuccessful) error("Antwort konnte nicht gesendet werden.")
|
||||
cache.clearContactRequestsCache()
|
||||
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||
}
|
||||
|
||||
suspend fun toggleContactRequestStatus(id: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||
val response = api.toggleContactRequestStatus(id)
|
||||
if (!response.isSuccessful) error("Status konnte nicht geändert werden.")
|
||||
cache.clearContactRequestsCache()
|
||||
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||
}
|
||||
|
||||
suspend fun newsletters(): Result<NewsletterListResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.newsletters()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putNewsletters,
|
||||
cached = { cache.getNewsletters(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Newsletter konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.newsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putNewsletterGroups,
|
||||
cached = { cache.getNewsletterGroups(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun passwordResetDiagnostics(
|
||||
email: String? = null,
|
||||
failedOnly: Boolean = true,
|
||||
): Result<PasswordResetDiagnosticsResponse> {
|
||||
val normalizedEmail = email?.trim().orEmpty()
|
||||
val canUseSharedCache = normalizedEmail.isBlank() && failedOnly
|
||||
return fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.passwordResetDiagnostics(
|
||||
email = normalizedEmail.takeIf { it.isNotBlank() },
|
||||
failedOnly = failedOnly,
|
||||
)
|
||||
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = { response ->
|
||||
if (canUseSharedCache) cache.putPasswordResetDiagnostics(response)
|
||||
},
|
||||
cached = {
|
||||
if (canUseSharedCache) {
|
||||
cache.getPasswordResetDiagnostics(PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun news(): Result<de.harheimertc.data.NewsResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||
response.body() ?: de.harheimertc.data.NewsResponse()
|
||||
},
|
||||
save = cache::putNews,
|
||||
cached = { cache.getNews(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "News konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun saveNews(request: de.harheimertc.data.NewsSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
cache.clearCmsNewsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
|
||||
val response = api.createNewsletter(request)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht erstellt werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun updateNewsletter(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
|
||||
val response = api.updateNewsletter(id, patch)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht aktualisiert werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun sendNewsletter(id: String): Result<de.harheimertc.data.NewsletterSendResponse> = runCatching {
|
||||
val response = api.sendNewsletter(id)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht versendet werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.NewsletterSendResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNewsletter(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNewsletter(id)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht gelöscht werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun createNewsletterGroup(payload: Map<String, @JvmSuppressWildcards Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
// use generic POST via Retrofit? build request through create endpoint
|
||||
val response = api.createNewsletterGroup(payload)
|
||||
if (!response.isSuccessful) error("Gruppe konnte nicht erstellt werden.")
|
||||
cache.clearNewsletterGroupsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun updateNewsletterGroup(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.updateNewsletterGroup(id, patch)
|
||||
if (!response.isSuccessful) error("Gruppe konnte nicht aktualisiert werden.")
|
||||
cache.clearNewsletterGroupsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNewsletterGroup(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNewsletterGroup(id)
|
||||
if (!response.isSuccessful) error("Gruppe konnte nicht gelöscht werden.")
|
||||
cache.clearNewsletterGroupsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
cache.clearCmsNewsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
private suspend fun <T> fetchEncryptedFallback(
|
||||
load: suspend () -> T,
|
||||
save: (T) -> Unit,
|
||||
cached: () -> T?,
|
||||
fallbackMessage: String,
|
||||
): Result<T> = runCatching {
|
||||
runCatching { load() }
|
||||
.onSuccess(save)
|
||||
.getOrElse { original ->
|
||||
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CmsMannschaftRow(
|
||||
val mannschaft: String = "",
|
||||
val liga: String = "",
|
||||
val staffelleiter: String = "",
|
||||
val telefon: String = "",
|
||||
val heimspieltag: String = "",
|
||||
val spielsystem: String = "",
|
||||
val mannschaftsfuehrer: String = "",
|
||||
val spieler: String = "",
|
||||
val informationenLink: String = "",
|
||||
val letzteAktualisierung: String = "",
|
||||
val spielplanMannschaftId: String = "",
|
||||
)
|
||||
|
||||
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||
val header = listOf(
|
||||
"Mannschaft",
|
||||
"Liga",
|
||||
"Staffelleiter",
|
||||
"Telefon",
|
||||
"Heimspieltag",
|
||||
"Spielsystem",
|
||||
"Mannschaftsführer",
|
||||
"Spieler",
|
||||
"Weitere Informationen Link",
|
||||
"Letzte Aktualisierung",
|
||||
"Spielplan Mannschaft ID",
|
||||
).toCsvRow()
|
||||
val rows = map { row ->
|
||||
listOf(
|
||||
row.mannschaft,
|
||||
row.liga,
|
||||
row.staffelleiter,
|
||||
row.telefon,
|
||||
row.heimspieltag,
|
||||
row.spielsystem,
|
||||
row.mannschaftsfuehrer,
|
||||
row.spieler,
|
||||
row.informationenLink,
|
||||
row.letzteAktualisierung,
|
||||
row.spielplanMannschaftId,
|
||||
).toCsvRow()
|
||||
}
|
||||
return listOf(header).plus(rows).joinToString("\n")
|
||||
}
|
||||
|
||||
private fun List<String>.toCsvRow(delimiter: String = ","): String =
|
||||
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
|
||||
|
||||
private fun String.csvEscape(delimiter: String): String {
|
||||
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||
val escaped = replace("\"", "\"\"")
|
||||
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<List<String>> =
|
||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||
|
||||
private fun parseCsvLine(line: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val value = StringBuilder()
|
||||
var quoted = false
|
||||
var index = 0
|
||||
while (index < line.length) {
|
||||
when (val char = line[index]) {
|
||||
'"' -> {
|
||||
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||
value.append('"')
|
||||
index++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
}
|
||||
',' -> if (quoted) value.append(char) else {
|
||||
values += value.toString().trim()
|
||||
value.clear()
|
||||
}
|
||||
else -> value.append(char)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += value.toString().trim()
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.data.ContactResponse
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ContactRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun sendContact(req: ContactRequest): Response<ContactResponse> = api.postContact(req)
|
||||
}
|
||||
124
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Executable file
124
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Executable file
@@ -0,0 +1,124 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.GalleryImageDto
|
||||
import de.harheimertc.data.GalleryPaginationDto
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GalleryRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.galerieList(page = 1, perPage = 1)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.images.orEmpty().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val resp = api.galerieList(page = page, perPage = perPage)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
GalleryPage(
|
||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||
)
|
||||
} else {
|
||||
error("HTTP ${resp.code()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadImage(uri: Uri, title: String, description: String, isPublic: Boolean): Result<Unit> = runCatching {
|
||||
val titleValue = title.trim()
|
||||
require(titleValue.isNotBlank()) { "Bitte einen Titel eintragen." }
|
||||
|
||||
val uploadFile = prepareCompressedUploadFile(uri)
|
||||
val mediaType = "image/jpeg".toMediaType()
|
||||
val imageBody = uploadFile.asRequestBody(mediaType)
|
||||
val imagePart = MultipartBody.Part.createFormData("image", uploadFile.name, imageBody)
|
||||
val textType = "text/plain".toMediaType()
|
||||
|
||||
val response = api.uploadGalleryImage(
|
||||
image = imagePart,
|
||||
title = titleValue.toRequestBody(textType),
|
||||
description = description.trim().toRequestBody(textType),
|
||||
isPublic = isPublic.toString().toRequestBody(textType),
|
||||
)
|
||||
uploadFile.delete()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body()
|
||||
if (body?.success == false) error(body.message ?: "Fehler beim Hochladen des Bildes")
|
||||
}
|
||||
|
||||
private fun GalleryImageDto.toGalleryImage(): GalleryImage {
|
||||
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||
return GalleryImage(
|
||||
id = id,
|
||||
title = title,
|
||||
description = description,
|
||||
isPublic = isPublic,
|
||||
uploadedAt = uploadedAt,
|
||||
previewUrl = "$base/api/media/galerie/$id?preview=true",
|
||||
imageUrl = "$base/api/media/galerie/$id",
|
||||
)
|
||||
}
|
||||
|
||||
private fun prepareCompressedUploadFile(uri: Uri): File {
|
||||
val inputBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: error("Bilddatei konnte nicht gelesen werden.")
|
||||
val original = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)
|
||||
?: error("Bilddatei konnte nicht verarbeitet werden.")
|
||||
val scaled = original.scaleInside(maxSize = 2000)
|
||||
val file = File(context.cacheDir, "gallery_upload_${System.currentTimeMillis()}.jpg")
|
||||
FileOutputStream(file).use { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||
}
|
||||
if (scaled !== original) scaled.recycle()
|
||||
original.recycle()
|
||||
return file
|
||||
}
|
||||
|
||||
private fun Bitmap.scaleInside(maxSize: Int): Bitmap {
|
||||
val largestSide = maxOf(width, height)
|
||||
if (largestSide <= maxSize) return this
|
||||
val scale = maxSize.toFloat() / largestSide.toFloat()
|
||||
val nextWidth = (width * scale).toInt().coerceAtLeast(1)
|
||||
val nextHeight = (height * scale).toInt().coerceAtLeast(1)
|
||||
return Bitmap.createScaledBitmap(this, nextWidth, nextHeight, true)
|
||||
}
|
||||
}
|
||||
|
||||
data class GalleryImage(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val isPublic: Boolean,
|
||||
val uploadedAt: String?,
|
||||
val previewUrl: String,
|
||||
val imageUrl: String,
|
||||
)
|
||||
|
||||
data class GalleryPage(
|
||||
val images: List<GalleryImage>,
|
||||
val pagination: GalleryPaginationDto,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class HomeLayoutPreferences @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val tag = "HomeLayoutPreferences"
|
||||
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_home_layout",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_home_layout")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_home_layout",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
fun getSections(): List<HomepageSectionDto>? {
|
||||
val json = preferences.getString(HOME_SECTIONS_KEY, null) ?: return null
|
||||
return runCatching { sectionListAdapter.fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
fun setSections(sections: List<HomepageSectionDto>) {
|
||||
val json = sectionListAdapter.toJson(sections)
|
||||
preferences.edit().putString(HOME_SECTIONS_KEY, json).apply()
|
||||
}
|
||||
|
||||
fun clearSections() {
|
||||
preferences.edit().remove(HOME_SECTIONS_KEY).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HOME_SECTIONS_KEY = "home_sections"
|
||||
}
|
||||
}
|
||||
260
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Executable file
260
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Executable file
@@ -0,0 +1,260 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.HeroImageVariantDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import io.sentry.Sentry
|
||||
import kotlin.random.Random
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class HomeData(
|
||||
val termine: List<TerminDto>,
|
||||
val spiele: List<SpielDto>,
|
||||
val spielplanSeasons: List<SeasonDto>,
|
||||
val selectedSpielplanSeason: String?,
|
||||
val news: List<NewsDto>,
|
||||
val homepageSections: List<HomepageSectionDto>,
|
||||
val heroImageUrl: String? = null,
|
||||
val diagnostics: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val termine = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.termine", error)
|
||||
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val spielplanResponse = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
val spiele = spielplanResponse?.data.orEmpty()
|
||||
|
||||
val news = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.publicNews()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.news.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.news", error)
|
||||
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val homepageSections = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.homepage?.sections.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.config", error)
|
||||
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val heroImageUrl = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.heroImages()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/hero-images",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
val variants = response.body()?.variants.orEmpty()
|
||||
pickRandomHeroImage(variants)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.heroImages", error)
|
||||
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/hero-images",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
HomeData(
|
||||
termine = termine,
|
||||
spiele = spiele,
|
||||
spielplanSeasons = spielplanResponse?.seasons.orEmpty(),
|
||||
selectedSpielplanSeason = spielplanResponse?.season,
|
||||
news = news,
|
||||
homepageSections = homepageSections,
|
||||
heroImageUrl = heroImageUrl,
|
||||
diagnostics = diagnostics,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchHomeData")
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchSpielplanForSeason")
|
||||
scope.setExtra("season", season)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureLoadIssue(operation: String, error: Throwable) {
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", operation)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDiagnostic(
|
||||
endpoint: String,
|
||||
requestPayload: String,
|
||||
httpCode: Int?,
|
||||
responseBody: String?,
|
||||
throwable: Throwable?,
|
||||
): String {
|
||||
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
|
||||
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
|
||||
return buildString {
|
||||
append("Endpoint: ").append(endpoint).append('\n')
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
|
||||
append("Request: ").append(requestPayload).append('\n')
|
||||
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
|
||||
append("Response: ").append(responsePreview).append('\n')
|
||||
append("Throwable: ").append(throwableInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
|
||||
if (variants.isEmpty()) return null
|
||||
val valid = variants.filter { it.fallback.isNotBlank() }
|
||||
if (valid.isEmpty()) return null
|
||||
val selected = valid[Random.nextInt(valid.size)]
|
||||
return toAbsoluteUrl(selected.fallback)
|
||||
}
|
||||
|
||||
private fun toAbsoluteUrl(pathOrUrl: String): String {
|
||||
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||
return pathOrUrl
|
||||
}
|
||||
|
||||
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
|
||||
return "$base$path"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LiveTickerRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetch(): Result<List<LiveTickerMatchDto>> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.liveTicker()
|
||||
if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.")
|
||||
response.body()?.matches ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
125
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Executable file
125
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Executable file
@@ -0,0 +1,125 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import io.sentry.Sentry
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LoginRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val endpoint = "api/auth/login"
|
||||
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
|
||||
if (!response.isSuccessful) {
|
||||
val body = response.errorBody()?.string().orEmpty()
|
||||
val serverMessage = extractServerMessage(body)
|
||||
val fallback = when (response.code()) {
|
||||
401 -> "Ungueltige Anmeldedaten"
|
||||
403 -> "Konto nicht freigeschaltet"
|
||||
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
|
||||
else -> "Anmeldung fehlgeschlagen"
|
||||
}
|
||||
val diagnostic = buildString {
|
||||
append("\n\nDiagnose:\n")
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
|
||||
append("Request: ").append(requestPreview).append('\n')
|
||||
append("HTTP: ").append(response.code()).append('\n')
|
||||
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
|
||||
append("Server message: ").append(serverMessage ?: "none")
|
||||
}
|
||||
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
|
||||
}
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "login")
|
||||
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
try {
|
||||
api.logout(LogoutRequest(authRepository.getRefreshToken()))
|
||||
} finally {
|
||||
authRepository.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(): Result<AuthStatusResponse> = runCatching {
|
||||
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
|
||||
return@runCatching AuthStatusResponse()
|
||||
}
|
||||
|
||||
var response = retryOnNetworkFailure { api.authStatus() }
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
var status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||
response = retryOnNetworkFailure { api.authStatus() }
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
status = response.body() ?: AuthStatusResponse()
|
||||
}
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||
status
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "status")
|
||||
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
|
||||
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.register(request)
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractServerMessage(raw: String): String? {
|
||||
if (raw.isBlank()) return null
|
||||
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
|
||||
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
|
||||
private fun maskEmail(rawEmail: String): String {
|
||||
val email = rawEmail.trim()
|
||||
if (!email.contains('@')) return "hidden"
|
||||
val local = email.substringBefore('@')
|
||||
val domain = email.substringAfter('@')
|
||||
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
|
||||
return "$localMasked@$domain"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class Mannschaft(
|
||||
val mannschaft: String,
|
||||
val liga: String,
|
||||
val staffelleiter: String,
|
||||
val telefon: String,
|
||||
val heimspieltag: String,
|
||||
val spielsystem: String,
|
||||
val mannschaftsfuehrer: String,
|
||||
val spieler: List<String>,
|
||||
val informationenLink: String,
|
||||
val letzteAktualisierung: String,
|
||||
) {
|
||||
val slug: String
|
||||
get() = mannschaft.lowercase(Locale.GERMANY).replace(Regex("\\s+"), "-")
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.mannschaftenSeasons()
|
||||
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||
.filter(String::isNotBlank)
|
||||
.drop(1)
|
||||
.mapNotNull { row ->
|
||||
val fields = parseCsvRow(row)
|
||||
if (fields.size < 10 || fields[0].isBlank()) return@mapNotNull null
|
||||
Mannschaft(
|
||||
mannschaft = fields[0],
|
||||
liga = fields[1],
|
||||
staffelleiter = fields[2],
|
||||
telefon = fields[3],
|
||||
heimspieltag = fields[4],
|
||||
spielsystem = fields[5],
|
||||
mannschaftsfuehrer = fields[6],
|
||||
spieler = fields[7].split(';').map(String::trim).filter(String::isNotBlank),
|
||||
informationenLink = fields[8],
|
||||
letzteAktualisierung = fields[9],
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
private fun parseCsvRow(row: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
var index = 0
|
||||
while (index < row.length) {
|
||||
val character = row[index]
|
||||
when {
|
||||
character == '"' && inQuotes && row.getOrNull(index + 1) == '"' -> {
|
||||
current.append('"')
|
||||
index++
|
||||
}
|
||||
character == '"' -> inQuotes = !inQuotes
|
||||
character == ',' && !inQuotes -> {
|
||||
values += current.toString().trim()
|
||||
current.clear()
|
||||
}
|
||||
else -> current.append(character)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += current.toString().trim()
|
||||
return values
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.QttrValuesResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import javax.inject.Inject
|
||||
|
||||
class MemberAreaRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val cache: SecureOfflineCache,
|
||||
) {
|
||||
suspend fun birthdays(): Result<BirthdaysResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.birthdays()
|
||||
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putBirthdays,
|
||||
cached = cache::getBirthdays,
|
||||
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun qttrValues(): Result<QttrValuesResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.qttrValues()
|
||||
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putQttrValues,
|
||||
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
|
||||
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun members(): Result<MembersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.members()
|
||||
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putMembers,
|
||||
cached = cache::getMembers,
|
||||
fallbackMessage = "Mitglieder konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun news(): Result<NewsResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) {
|
||||
try {
|
||||
val body = response.errorBody()?.string()
|
||||
android.util.Log.w("MemberAreaRepository", "memberNews failed: code=${response.code()} body=${body?.take(500)}")
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
error("News konnten nicht geladen werden.")
|
||||
}
|
||||
response.body() ?: run {
|
||||
android.util.Log.w("MemberAreaRepository", "memberNews: successful but empty body (null)")
|
||||
NewsResponse(success = false, news = emptyList())
|
||||
}
|
||||
},
|
||||
save = cache::putNews,
|
||||
cached = cache::getNews,
|
||||
fallbackMessage = "News konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
}
|
||||
|
||||
suspend fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.saveMember(request)
|
||||
if (!response.isSuccessful) error("Mitglied konnte nicht gespeichert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteMember(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteMember(mapOf("id" to id))
|
||||
if (!response.isSuccessful) error("Mitglied konnte nicht gelöscht werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun bulkImport(members: List<Map<String, String>>): Result<de.harheimertc.data.ApiService.BulkImportResponse> = runCatching {
|
||||
val response = api.bulkImportMembers(de.harheimertc.data.ApiService.BulkImportRequest(members))
|
||||
if (!response.isSuccessful) error("Bulk-Import fehlgeschlagen")
|
||||
response.body() ?: de.harheimertc.data.ApiService.BulkImportResponse(success = false)
|
||||
}
|
||||
|
||||
suspend fun toggleMannschaftsspieler(memberId: String): Result<Map<String, Any>> = runCatching {
|
||||
val response = api.toggleMannschaftsspieler(mapOf("memberId" to memberId))
|
||||
if (!response.isSuccessful) error("Status konnte nicht umgeschaltet werden.")
|
||||
response.body() ?: emptyMap()
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
}
|
||||
|
||||
private suspend fun <T> fetchEncryptedFallback(
|
||||
load: suspend () -> T,
|
||||
save: (T) -> Unit,
|
||||
cached: () -> T?,
|
||||
fallbackMessage: String,
|
||||
): Result<T> = runCatching {
|
||||
runCatching { load() }
|
||||
.onSuccess(save)
|
||||
.getOrElse { original ->
|
||||
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.FileProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class MembershipDocument(val message: String, val uri: String? = null)
|
||||
|
||||
@Singleton
|
||||
class MembershipRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
||||
val response = api.generateMembershipPdf(request)
|
||||
if (!response.isSuccessful) {
|
||||
val serverMessage = response.errorBody()?.string()
|
||||
?.let { Regex("\\\"(?:statusMessage|message)\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"").find(it)?.groupValues?.getOrNull(1) ?: it }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
error(serverMessage ?: "Der Antrag konnte nicht übermittelt werden (HTTP ${response.code()}).")
|
||||
}
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
||||
val uri = body.downloadUrl?.let { downloadUrl ->
|
||||
runCatching {
|
||||
val documentResponse = api.downloadMembershipPdf(downloadUrl, body.downloadToken)
|
||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||
val file = File(directory, "beitrittserklaerung.pdf")
|
||||
documentResponse.body()?.byteStream()?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Leere PDF-Antwort")
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.files", file).toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
MembershipDocument(
|
||||
message = if (uri == null) {
|
||||
"${body.message ?: "Mitgliedschaftsantrag erfolgreich übermittelt."} Das PDF konnte nicht auf diesem Gerät gespeichert werden."
|
||||
} else {
|
||||
body.message ?: "Beitrittsformular erfolgreich erstellt."
|
||||
},
|
||||
uri = uri,
|
||||
)
|
||||
}
|
||||
}
|
||||
33
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file
33
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal suspend fun <T> retryOnNetworkFailure(
|
||||
retryDelayMillis: Long = 10_000L,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
if (!error.isRetryableNetworkError()) throw error
|
||||
delay(retryDelayMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
|
||||
is UnknownHostException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException,
|
||||
is SSLException -> true
|
||||
else -> false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterSubscriptionRequest
|
||||
import javax.inject.Inject
|
||||
|
||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.publicNewsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty()))
|
||||
if (!response.isSuccessful) error("Newsletter-Anmeldung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Eine Bestätigungsmail wurde versendet.")
|
||||
}
|
||||
|
||||
suspend fun unsubscribe(groupId: String, email: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.unsubscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim()))
|
||||
if (!response.isSuccessful) error("Newsletter-Abmeldung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Sie wurden abgemeldet.")
|
||||
}
|
||||
|
||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.confirmNewsletter(token)
|
||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.NotificationSettingsDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val DEFAULT_NOTIFICATION_TIME = "09:00"
|
||||
|
||||
data class NotificationPreferences(
|
||||
val newNews: Boolean = false,
|
||||
val newEvents: Boolean = false,
|
||||
val eventsToday: Boolean = false,
|
||||
val eventsTomorrow: Boolean = false,
|
||||
val ownTeamMatches: Boolean = false,
|
||||
val allTeamMatches: Boolean = false,
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class NotificationPreferencesRepository @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiService,
|
||||
) {
|
||||
private val preferences by lazy {
|
||||
context.getSharedPreferences("harheimertc_notification_preferences", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun loadLocal(): NotificationPreferences = NotificationPreferences(
|
||||
newNews = preferences.getBoolean(KEY_NEW_NEWS, false),
|
||||
newEvents = preferences.getBoolean(KEY_NEW_EVENTS, false),
|
||||
eventsToday = preferences.getBoolean(KEY_EVENTS_TODAY, false),
|
||||
eventsTomorrow = preferences.getBoolean(KEY_EVENTS_TOMORROW, false),
|
||||
ownTeamMatches = preferences.getBoolean(KEY_OWN_TEAM_MATCHES, false),
|
||||
allTeamMatches = preferences.getBoolean(KEY_ALL_TEAM_MATCHES, false),
|
||||
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
|
||||
ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false),
|
||||
liveTickerStarts = preferences.getBoolean(KEY_LIVE_TICKER_STARTS, false),
|
||||
liveTickerScoreChanges = preferences.getBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, false),
|
||||
liveTickerFinished = preferences.getBoolean(KEY_LIVE_TICKER_FINISHED, false),
|
||||
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||
)
|
||||
|
||||
suspend fun loadRemote(): Result<NotificationPreferences> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.notificationSettings()
|
||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht geladen werden.")
|
||||
val settings = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||
saveLocal(settings)
|
||||
settings
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocal(settings: NotificationPreferences) {
|
||||
preferences.edit()
|
||||
.putBoolean(KEY_NEW_NEWS, settings.newNews)
|
||||
.putBoolean(KEY_NEW_EVENTS, settings.newEvents)
|
||||
.putBoolean(KEY_EVENTS_TODAY, settings.eventsToday)
|
||||
.putBoolean(KEY_EVENTS_TOMORROW, settings.eventsTomorrow)
|
||||
.putBoolean(KEY_OWN_TEAM_MATCHES, settings.ownTeamMatches)
|
||||
.putBoolean(KEY_ALL_TEAM_MATCHES, settings.allTeamMatches)
|
||||
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
||||
.putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges)
|
||||
.putBoolean(KEY_LIVE_TICKER_STARTS, settings.liveTickerStarts)
|
||||
.putBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, settings.liveTickerScoreChanges)
|
||||
.putBoolean(KEY_LIVE_TICKER_FINISHED, settings.liveTickerFinished)
|
||||
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||
.apply()
|
||||
}
|
||||
|
||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateNotificationSettings(settings.toDto())
|
||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||
requireLiveNotificationSettingsSaved(settings, saved)
|
||||
saved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_NEW_NEWS = "new_news"
|
||||
const val KEY_NEW_EVENTS = "new_events"
|
||||
const val KEY_EVENTS_TODAY = "events_today"
|
||||
const val KEY_EVENTS_TOMORROW = "events_tomorrow"
|
||||
const val KEY_OWN_TEAM_MATCHES = "own_team_matches"
|
||||
const val KEY_ALL_TEAM_MATCHES = "all_team_matches"
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
||||
const val KEY_OWN_TTR_CHANGES = "own_ttr_changes"
|
||||
const val KEY_LIVE_TICKER_STARTS = "live_ticker_starts"
|
||||
const val KEY_LIVE_TICKER_SCORE_CHANGES = "live_ticker_score_changes"
|
||||
const val KEY_LIVE_TICKER_FINISHED = "live_ticker_finished"
|
||||
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
|
||||
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requireLiveNotificationSettingsSaved(requested: NotificationPreferences, saved: NotificationPreferences) {
|
||||
if (requested.liveTickerStarts != saved.liveTickerStarts ||
|
||||
requested.liveTickerScoreChanges != saved.liveTickerScoreChanges ||
|
||||
requested.liveTickerFinished != saved.liveTickerFinished
|
||||
) {
|
||||
error("Der Server hat die Live-Benachrichtigungen nicht übernommen. Bitte das Backend aktualisieren und erneut versuchen.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
eventsToday = eventsToday,
|
||||
eventsTomorrow = eventsTomorrow,
|
||||
ownTeamMatches = ownTeamMatches,
|
||||
allTeamMatches = allTeamMatches,
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
)
|
||||
|
||||
private fun NotificationPreferences.toDto(): NotificationSettingsDto = NotificationSettingsDto(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
eventsToday = eventsToday,
|
||||
eventsTomorrow = eventsTomorrow,
|
||||
ownTeamMatches = ownTeamMatches,
|
||||
allTeamMatches = allTeamMatches,
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
)
|
||||
120
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Executable file
120
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Executable file
@@ -0,0 +1,120 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.credentials.CreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.CreatePublicKeyCredentialResponse
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetPublicKeyCredentialOption
|
||||
import androidx.credentials.PublicKeyCredential
|
||||
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.MediaTypes
|
||||
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
|
||||
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
|
||||
import de.harheimertc.data.PasskeysResponse
|
||||
import de.harheimertc.data.RemovePasskeyRequest
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PasskeyRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||
)
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.getCredential(
|
||||
context = context,
|
||||
request = GetCredentialRequest(
|
||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||
),
|
||||
)
|
||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.passkeyLogin(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||
.put("client", "android")
|
||||
.put("deviceName", "Harheimer TC Android-App")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}
|
||||
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
||||
|
||||
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.passkeys()
|
||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.createCredential(
|
||||
context = context,
|
||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||
) as? CreatePublicKeyCredentialResponse
|
||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.registerPasskey(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||
.put("name", name)
|
||||
.put("client", "android")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
||||
|
||||
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.removePasskey(RemovePasskeyRequest(credentialId))
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht entfernt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
private fun String.extractJsonObject(key: String): String? {
|
||||
val root = JSONObject(this)
|
||||
return root.optJSONObject(key)?.toString()
|
||||
}
|
||||
|
||||
private fun <T> Result<T>.recoverCredentialCancellation(message: String): Result<T> =
|
||||
recoverCatching { error ->
|
||||
when (error) {
|
||||
is GetCredentialCancellationException,
|
||||
is CreateCredentialCancellationException -> throw IllegalStateException(message)
|
||||
else -> throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ProfileResponse
|
||||
import de.harheimertc.data.ProfileUpdateRequest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun load(): Result<ProfileResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.profile()
|
||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateProfile(request)
|
||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.LinkItemDto
|
||||
import de.harheimertc.data.LinkSectionDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class Spielsystem(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val teamSize: String,
|
||||
val category: String,
|
||||
val sequence: String,
|
||||
val gameCount: String,
|
||||
val features: String,
|
||||
)
|
||||
|
||||
data class MeisterschaftResult(
|
||||
val year: String,
|
||||
val category: String,
|
||||
val rank: String,
|
||||
val playerOne: String,
|
||||
val playerTwo: String,
|
||||
val note: String,
|
||||
val imageOne: String,
|
||||
val imageTwo: String,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 8) return@mapNotNull null
|
||||
Spielsystem(
|
||||
name = values[0],
|
||||
description = values[1],
|
||||
teamSize = values[2],
|
||||
category = values[3],
|
||||
sequence = values[5],
|
||||
gameCount = values[6],
|
||||
features = values[7],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<List<String>> =
|
||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||
|
||||
private fun parseCsvLine(line: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val value = StringBuilder()
|
||||
var quoted = false
|
||||
var index = 0
|
||||
while (index < line.length) {
|
||||
when (val char = line[index]) {
|
||||
'"' -> {
|
||||
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||
value.append('"')
|
||||
index++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
}
|
||||
',' -> if (quoted) value.append(char) else {
|
||||
values += value.toString().trim()
|
||||
value.clear()
|
||||
}
|
||||
else -> value.append(char)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += value.toString().trim()
|
||||
return values
|
||||
}
|
||||
|
||||
fun ConfigResponse.linkSections(): List<LinkSectionDto> =
|
||||
seiten.linksStructured.filter { it.title.isNotBlank() && it.items.isNotEmpty() }
|
||||
.ifEmpty {
|
||||
parseLinkSections(seiten.links).ifEmpty { defaultLinkSections }
|
||||
}
|
||||
|
||||
private fun parseLinkSections(html: String): List<LinkSectionDto> {
|
||||
if (html.isBlank()) return emptyList()
|
||||
val sectionRegex = Regex("""<h2[^>]*>(.*?)</h2>(.*?)(?=<h2[^>]*>|$)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
val itemRegex = Regex("""<li[^>]*>(.*?)</li>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
val anchorRegex = Regex("""<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
return sectionRegex.findAll(html).mapNotNull { section ->
|
||||
val title = stripHtml(section.groupValues[1])
|
||||
val items = itemRegex.findAll(section.groupValues[2]).mapNotNull { item ->
|
||||
val match = anchorRegex.find(item.groupValues[1]) ?: return@mapNotNull null
|
||||
LinkItemDto(
|
||||
href = match.groupValues[1].trim(),
|
||||
label = stripHtml(match.groupValues[2]),
|
||||
description = stripHtml(item.groupValues[1].replace(match.value, "")),
|
||||
)
|
||||
}.toList()
|
||||
title.takeIf { it.isNotBlank() && items.isNotEmpty() }?.let { LinkSectionDto(it, items) }
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun stripHtml(html: String): String = html
|
||||
.replace(Regex("<[^>]*>"), "")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
|
||||
private val defaultLinkSections = listOf(
|
||||
LinkSectionDto("Ergebnisse & Portale", listOf(
|
||||
LinkItemDto("MyTischtennis.de", "http://www.mytischtennis.de/public/home", "(offizielle QTTR-Werte)"),
|
||||
LinkItemDto("Click-tt Ergebnisse", "http://httv.click-tt.de/", "(offizieller Ergebnisdienst HTTV)"),
|
||||
LinkItemDto("Tischtennis Pur", "https://www.tischtennis-pur.de/", "(Informationen, Blogs und Tipps)"),
|
||||
LinkItemDto("Liveticker 2. und 3. TT-Bundesliga", "https://ticker.tt-news.com/"),
|
||||
)),
|
||||
LinkSectionDto("Verbände", listOf(
|
||||
LinkItemDto("Hessischer Tischtennisverband (HTTV)", "http://www.httv.de/"),
|
||||
LinkItemDto("Deutscher Tischtennisbund (DTTB)", "http://www.tischtennis.de/aktuelles/"),
|
||||
LinkItemDto("European Table Tennis Union (ETTU)", "http://www.ettu.org/"),
|
||||
LinkItemDto("International Table Tennis Federation (ITTF)", "https://www.ittf.com/"),
|
||||
)),
|
||||
LinkSectionDto("Regionale Links", listOf(
|
||||
LinkItemDto("Stadt Frankfurt", "http://www.frankfurt.de/"),
|
||||
LinkItemDto("Vereinsring Harheim", "http://www.harheim.com/"),
|
||||
)),
|
||||
LinkSectionDto("Partner & Vereine", listOf(
|
||||
LinkItemDto("TTC OE Bad Homburg", "http://www.ttcoe.de/"),
|
||||
LinkItemDto("SpVgg Steinkirchen e.V.", "https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis"),
|
||||
LinkItemDto("Ergebnisse SpVgg Steinkirchen", "https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/"),
|
||||
)),
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.PushTokenRequest
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PushTokenRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
) {
|
||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
val installationId = FirebaseInstallations.getInstance().id.await()
|
||||
registerToken(token, installationId).getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching {
|
||||
if (token.isBlank()) return@runCatching
|
||||
retryOnNetworkFailure {
|
||||
val response = api.registerPushToken(
|
||||
PushTokenRequest(
|
||||
token = token,
|
||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||
installationId = installationId,
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
val detail = response.errorBody()?.string().orEmpty().replace(Regex("\\s+"), " ").take(180)
|
||||
error("Push-Token konnte nicht registriert werden (HTTP ${response.code()})${if (detail.isBlank()) "" else ": $detail"}")
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TeamTableResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplanTable(team, season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
}
|
||||
74
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Executable file
74
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Executable file
@@ -0,0 +1,74 @@
|
||||
package de.harheimertc.security
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.Signature
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceKeyManager @Inject constructor(@param:ApplicationContext private val context: Context) {
|
||||
private val alias = "harheimertc_device_key"
|
||||
private val keyStore: KeyStore by lazy {
|
||||
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
}
|
||||
|
||||
fun ensureKeyPair(): String? {
|
||||
try {
|
||||
if (!keyStore.containsAlias(alias)) {
|
||||
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
|
||||
val specBuilder = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||
)
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||
.setUserAuthenticationRequired(false)
|
||||
|
||||
// For older APIs, KeyGenParameterSpec.Builder methods exist from API 23+
|
||||
kpg.initialize(specBuilder.build())
|
||||
kpg.generateKeyPair()
|
||||
}
|
||||
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||
return Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPublicKeyBase64(): String? {
|
||||
return try {
|
||||
if (!keyStore.containsAlias(alias)) return null
|
||||
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||
Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun sign(data: ByteArray): ByteArray? {
|
||||
return try {
|
||||
val privateKey = keyStore.getKey(alias, null) as? java.security.PrivateKey ?: return null
|
||||
val sig = Signature.getInstance("SHA256withECDSA")
|
||||
sig.initSign(privateKey)
|
||||
sig.update(data)
|
||||
sig.sign()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteKey() {
|
||||
try {
|
||||
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.navigation.NavigationUiState
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
private enum class MenuSection {
|
||||
VEREIN,
|
||||
MANNSCHAFTEN,
|
||||
TRAINING,
|
||||
NEWSLETTER,
|
||||
INTERN,
|
||||
CMS,
|
||||
}
|
||||
|
||||
private data class MenuTarget(val label: String, val route: String)
|
||||
private const val LOGOUT_ROUTE = "__logout__"
|
||||
|
||||
@Composable
|
||||
fun AppNavigationHeader(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit = {},
|
||||
webTabletNavigation: Boolean = false,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.horizontalGradient(listOf(Accent900, Primary900, Accent900)))
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (webTabletNavigation) {
|
||||
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||
} else {
|
||||
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
val routeSection = menuSection(selectedRoute)
|
||||
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = sectionOverride.value ?: routeSection
|
||||
val subItems = submenu(section, navigationState)
|
||||
|
||||
val mainScroll = rememberScrollState()
|
||||
val subScroll = rememberScrollState()
|
||||
|
||||
BrandRow(
|
||||
loggedIn = navigationState.loggedIn,
|
||||
onLogin = { onNavigate(Destinations.Login.route) },
|
||||
onLogout = onLogout,
|
||||
)
|
||||
|
||||
ScrollableMenuRow(scrollState = mainScroll) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||
if (navigationState.showGallery) {
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||
if (navigationState.canAccessCms) {
|
||||
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
|
||||
}
|
||||
} else {
|
||||
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
}
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
}
|
||||
|
||||
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
if (item.route == LOGOUT_ROUTE) {
|
||||
onLogout()
|
||||
} else {
|
||||
onNavigate(item.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactSectionLink(
|
||||
label: String,
|
||||
section: MenuSection,
|
||||
activeSection: MenuSection?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (activeSection == section) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onSelect() },
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebTabletNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
navigationState: NavigationUiState,
|
||||
) {
|
||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = sectionOverride.value ?: menuSection(selectedRoute)
|
||||
val subScroll = rememberScrollState()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Home.route)
|
||||
})
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
|
||||
sectionOverride.value = MenuSection.VEREIN
|
||||
onNavigate(Destinations.VereinAbout.route)
|
||||
})
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
|
||||
sectionOverride.value = MenuSection.MANNSCHAFTEN
|
||||
onNavigate(Destinations.Mannschaften.route)
|
||||
})
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = {
|
||||
sectionOverride.value = MenuSection.TRAINING
|
||||
onNavigate(Destinations.Training.route)
|
||||
})
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Termine.route)
|
||||
})
|
||||
if (navigationState.showGallery) {
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
|
||||
sectionOverride.value = MenuSection.VEREIN
|
||||
onNavigate(Destinations.Gallery.route)
|
||||
})
|
||||
}
|
||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
|
||||
sectionOverride.value = MenuSection.NEWSLETTER
|
||||
onNavigate(Destinations.NewsletterSubscribe.route)
|
||||
})
|
||||
if (navigationState.loggedIn) {
|
||||
MainLink("Intern", section == MenuSection.INTERN, onClick = {
|
||||
sectionOverride.value = MenuSection.INTERN
|
||||
})
|
||||
if (navigationState.canAccessCms) {
|
||||
MainLink("CMS", section == MenuSection.CMS, onClick = {
|
||||
sectionOverride.value = MenuSection.CMS
|
||||
onNavigate(Destinations.Cms.route)
|
||||
})
|
||||
}
|
||||
}
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Contact.route)
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
if (navigationState.loggedIn) {
|
||||
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Login.route)
|
||||
}) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
|
||||
val subItems = submenu(section, navigationState)
|
||||
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
if (item.route == LOGOUT_ROUTE) {
|
||||
onLogout()
|
||||
} else {
|
||||
onNavigate(item.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrandRow(
|
||||
loggedIn: Boolean,
|
||||
onLogin: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (loggedIn) {
|
||||
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||
} else {
|
||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Brand() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.harheimer_tc_logo),
|
||||
contentDescription = "Harheimer TC Logo",
|
||||
modifier = Modifier.size(42.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Harheimer ", color = Color.White, style = MaterialTheme.typography.titleLarge)
|
||||
Text("TC", color = Color(0xFFF87171), style = MaterialTheme.typography.titleLarge)
|
||||
if (BuildConfig.ENVIRONMENT_NAME.isNotBlank()) {
|
||||
Text(
|
||||
BuildConfig.ENVIRONMENT_NAME,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.background(Primary600, RoundedCornerShape(5.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainLink(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
primary: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected || primary) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactLink(
|
||||
label: String,
|
||||
route: String,
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
beforeNavigate: () -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable {
|
||||
beforeNavigate()
|
||||
onNavigate(route)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollableMenuRow(
|
||||
scrollState: ScrollState,
|
||||
topPadding: Dp = 0.dp,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = topPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (scrollState.canScrollBackward) "◀" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.width(14.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(scrollState),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
Text(
|
||||
if (scrollState.canScrollForward) "▶" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.width(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.VereinAbout.route,
|
||||
Destinations.Vorstand.route,
|
||||
Destinations.Geschichte.route,
|
||||
Destinations.Satzung.route,
|
||||
Destinations.Vereinsmeisterschaften.route,
|
||||
Destinations.Links.route,
|
||||
Destinations.Impressum.route,
|
||||
Destinations.Gallery.route -> MenuSection.VEREIN
|
||||
|
||||
Destinations.Mannschaften.route,
|
||||
Destinations.Spielplan.route,
|
||||
Destinations.Spielsysteme.route -> MenuSection.MANNSCHAFTEN
|
||||
|
||||
Destinations.Training.route,
|
||||
Destinations.Trainer.route,
|
||||
Destinations.Anfaenger.route,
|
||||
Destinations.Regeln.route -> MenuSection.TRAINING
|
||||
|
||||
Destinations.NewsletterSubscribe.route,
|
||||
Destinations.NewsletterUnsubscribe.route,
|
||||
Destinations.NewsletterConfirm.route,
|
||||
Destinations.NewsletterConfirmed.route,
|
||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||
|
||||
Destinations.MemberArea.route,
|
||||
Destinations.Members.route,
|
||||
Destinations.Qttr.route,
|
||||
Destinations.MemberNews.route,
|
||||
Destinations.Profile.route,
|
||||
Destinations.NotificationSettings.route,
|
||||
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||
|
||||
Destinations.CmsStartseite.route,
|
||||
Destinations.CmsInhalte.route,
|
||||
Destinations.CmsVereinsmeisterschaften.route,
|
||||
Destinations.CmsNews.route,
|
||||
Destinations.CmsSportbetrieb.route,
|
||||
Destinations.CmsMitgliederverwaltung.route,
|
||||
Destinations.CmsNewsletter.route,
|
||||
Destinations.CmsContactRequests.route,
|
||||
Destinations.CmsEinstellungen.route,
|
||||
Destinations.CmsBenutzer.route,
|
||||
Destinations.CmsPasswordResetDiagnostics.route,
|
||||
Destinations.Cms.route -> MenuSection.CMS
|
||||
|
||||
else -> null
|
||||
}.let { section ->
|
||||
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
||||
}
|
||||
|
||||
private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuTarget> = when (section) {
|
||||
MenuSection.VEREIN -> listOf(
|
||||
MenuTarget("Über uns", Destinations.VereinAbout.route),
|
||||
MenuTarget("Vorstand", Destinations.Vorstand.route),
|
||||
MenuTarget("Geschichte", Destinations.Geschichte.route),
|
||||
MenuTarget("Satzung", Destinations.Satzung.route),
|
||||
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
|
||||
MenuTarget("Galerie", Destinations.Gallery.route),
|
||||
MenuTarget("Links", Destinations.Links.route),
|
||||
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||
)
|
||||
|
||||
MenuSection.MANNSCHAFTEN -> listOf(
|
||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||
)
|
||||
|
||||
MenuSection.TRAINING -> listOf(
|
||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||
)
|
||||
|
||||
MenuSection.NEWSLETTER -> listOf(
|
||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||
)
|
||||
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||
add(MenuTarget("TTR / QTTR", Destinations.Qttr.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||
}
|
||||
|
||||
MenuSection.CMS -> buildList {
|
||||
if (state.canAccessFullCms) {
|
||||
add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
add(MenuTarget("News", Destinations.CmsNews.route))
|
||||
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
|
||||
}
|
||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||
}
|
||||
|
||||
null -> emptyList()
|
||||
}
|
||||
56
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Executable file
56
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Executable file
@@ -0,0 +1,56 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
@Composable
|
||||
fun ValidatedTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
error: String? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
isError = error != null,
|
||||
supportingText = error?.let { { Text(it) } },
|
||||
keyboardOptions = keyboardOptions,
|
||||
visualTransformation = visualTransformation,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FormMessages(error: String?, message: String?) {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
|
||||
internal fun isValidEmail(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
return trimmed.length in 5..254 &&
|
||||
trimmed.count { it == '@' } == 1 &&
|
||||
trimmed.substringBefore('@').isNotBlank() &&
|
||||
trimmed.substringAfter('@').contains('.') &&
|
||||
!trimmed.any(Char::isWhitespace)
|
||||
}
|
||||
|
||||
internal fun isValidIsoDate(value: String): Boolean =
|
||||
value.trim().matches(Regex("\\d{4}-\\d{2}-\\d{2}"))
|
||||
|
||||
81
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Executable file
81
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Executable file
@@ -0,0 +1,81 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun HeroComponent(
|
||||
imageUrl: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
ctaText: String,
|
||||
onPrimaryCta: () -> Unit,
|
||||
heightDp: Int = 280
|
||||
) {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heightDp.dp)) {
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Hero Image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heightDp.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Box(modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color(0x66000000), Color(0x00000000), Color(0x88000000))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Box(modifier = Modifier
|
||||
.matchParentSize()
|
||||
.padding(20.dp)) {
|
||||
Column(modifier = Modifier.align(Alignment.CenterStart)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = Color.White)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Button(
|
||||
onClick = onPrimaryCta,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier
|
||||
) {
|
||||
Text(ctaText, color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Executable file
86
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Executable file
@@ -0,0 +1,86 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import coil.request.ImageRequest
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.repositories.GalleryImage
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImageGrid(images: List<GalleryImage>, modifier: Modifier = Modifier) {
|
||||
val selected = remember { mutableStateOf<GalleryImage?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
|
||||
items(images) { img ->
|
||||
val description = stringResource(R.string.gallery_image_description, img.title.ifBlank { img.id })
|
||||
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(img.previewUrl)
|
||||
.size(300, 300)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = description,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.semantics { contentDescription = description }
|
||||
.clickable { selected.value = img },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.value != null) {
|
||||
Dialog(onDismissRequest = { selected.value = null }) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = selected.value?.imageUrl,
|
||||
contentDescription = selected.value?.title ?: stringResource(R.string.gallery_title),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Button(
|
||||
onClick = { selected.value = null },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.semantics { contentDescription = context.getString(R.string.gallery_close_image) },
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
) {
|
||||
Text(stringResource(R.string.gallery_upload_hide))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@Composable
|
||||
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||
var observedLoadedState by remember { mutableStateOf(false) }
|
||||
var previousStates by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
LaunchedEffect(matches, loaded) {
|
||||
if (!loaded) {
|
||||
observedLoadedState = false
|
||||
previousStates = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val currentStates = matches.associate { match ->
|
||||
(match.meetingId.ifBlank { "${match.homeTeam}-${match.awayTeam}-${match.startsAt}" }) to
|
||||
"${match.hasLiveData}|${match.score}|${match.completed}"
|
||||
}
|
||||
if (observedLoadedState && currentStates.any { (id, state) -> previousStates[id] != state }) {
|
||||
LiveTickerSound.play()
|
||||
}
|
||||
previousStates = currentStates
|
||||
observedLoadedState = true
|
||||
}
|
||||
|
||||
if (matches.isEmpty()) return
|
||||
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = { paused = !paused },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = if (paused) "Laufschrift fortsetzen" else "Laufschrift anhalten"
|
||||
},
|
||||
) {
|
||||
Text(if (paused) "▶" else "Ⅱ", color = Color(0xFF334155))
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.weight(1f).clipToBounds()) {
|
||||
val viewportWidth = maxWidth
|
||||
var groupWidth by remember { mutableStateOf(0) }
|
||||
val offset = remember { Animatable(0f) }
|
||||
val pixelsPerSecond = with(LocalDensity.current) { 36.dp.toPx() }
|
||||
LaunchedEffect(groupWidth, pixelsPerSecond, paused) {
|
||||
if (groupWidth <= 0 || paused) return@LaunchedEffect
|
||||
offset.snapTo(offset.value % groupWidth)
|
||||
while (isActive) {
|
||||
val duration = ((groupWidth - offset.value) / pixelsPerSecond * 1000).toInt().coerceAtLeast(1)
|
||||
offset.animateTo(groupWidth.toFloat(), tween(duration, easing = LinearEasing))
|
||||
offset.snapTo(0f)
|
||||
// Also yield when system animations are disabled.
|
||||
withFrameNanos { }
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||
.graphicsLayer { translationX = -offset.value },
|
||||
) {
|
||||
repeat(2) { copy ->
|
||||
LiveTickerGroup(
|
||||
matches = matches,
|
||||
modifier = Modifier
|
||||
.widthIn(min = viewportWidth)
|
||||
.then(if (copy == 0) Modifier.onSizeChanged { groupWidth = it.width } else Modifier.clearAndSetSemantics {}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveTickerGroup(matches: List<LiveTickerMatchDto>, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.padding(start = 16.dp, end = 32.dp, top = 9.dp, bottom = 9.dp),
|
||||
) {
|
||||
matches.forEachIndexed { index, match ->
|
||||
if (index > 0) {
|
||||
Text(" • ", color = Color(0xFF64748B))
|
||||
}
|
||||
val status = when {
|
||||
match.completed -> "✓ beendet"
|
||||
match.hasLiveData -> "● LIVE"
|
||||
else -> "◌ Keine Live-Daten"
|
||||
}
|
||||
Text(
|
||||
text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}",
|
||||
color = when {
|
||||
match.completed -> Color(0xFF64748B)
|
||||
match.hasLiveData -> Color(0xFFB91C1C)
|
||||
else -> Color(0xFF334155)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold,
|
||||
fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
|
||||
/** A generated system tone; no audio asset or third-party licence is required. */
|
||||
object LiveTickerSound {
|
||||
private val toneGenerator by lazy { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 65) }
|
||||
|
||||
fun play() {
|
||||
runCatching {
|
||||
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP2, 170)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file
24
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
internal fun LoadingState(message: String = "Daten werden geladen...") {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text(message, color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun NativeRichTextEditor(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var fieldValue by remember(value) { mutableStateOf(TextFieldValue(value, TextRange(value.length))) }
|
||||
var linkDialog by remember { mutableStateOf(false) }
|
||||
var imageDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun commit(next: TextFieldValue) {
|
||||
fieldValue = next
|
||||
onValueChange(normalizeEmptyHtml(next.text))
|
||||
}
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleLarge)
|
||||
ToolbarRow(
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
RichTextAction.Link -> linkDialog = true
|
||||
RichTextAction.Image -> imageDialog = true
|
||||
RichTextAction.Clean -> commit(fieldValue.copy(text = stripHtml(fieldValue.text), selection = TextRange(stripHtml(fieldValue.text).length)))
|
||||
else -> commit(applyAction(fieldValue, action))
|
||||
}
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { commit(it) },
|
||||
label = { Text("HTML-Inhalt") },
|
||||
minLines = 12,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Surface(color = Color(0xFFF4F4F5), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Vorschau", style = MaterialTheme.typography.labelLarge)
|
||||
if (fieldValue.text.isBlank()) Text("Noch kein Inhalt.", color = Color(0xFF71717A)) else RichText(fieldValue.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (linkDialog) {
|
||||
UrlDialog(
|
||||
title = "Link einfügen",
|
||||
placeholder = "https://...",
|
||||
onDismiss = { linkDialog = false },
|
||||
onConfirm = { url ->
|
||||
commit(applyLink(fieldValue, url))
|
||||
linkDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (imageDialog) {
|
||||
UrlDialog(
|
||||
title = "Bild einfügen",
|
||||
placeholder = "https://.../bild.jpg",
|
||||
onDismiss = { imageDialog = false },
|
||||
onConfirm = { url ->
|
||||
commit(insertHtml(fieldValue, """<p><img src="${escapeHtml(url)}"></p>"""))
|
||||
imageDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarRow(onAction: (RichTextAction) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RichTextAction.entries.forEach { action ->
|
||||
AssistChip(onClick = { onAction(action) }, label = { Text(action.label) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UrlDialog(title: String, placeholder: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var value by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(placeholder) }, singleLine = true)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text("Einfügen") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private enum class RichTextAction(val label: String) {
|
||||
H1("H1"),
|
||||
H2("H2"),
|
||||
H3("H3"),
|
||||
Bold("B"),
|
||||
Italic("I"),
|
||||
Underline("U"),
|
||||
Strike("S"),
|
||||
Color("Farbe"),
|
||||
Background("Marker"),
|
||||
OrderedList("1."),
|
||||
BulletList("•"),
|
||||
AlignCenter("Zentriert"),
|
||||
Link("Link"),
|
||||
Image("Bild"),
|
||||
Blockquote("Zitat"),
|
||||
CodeBlock("Code"),
|
||||
Clean("Clean"),
|
||||
}
|
||||
|
||||
private fun applyAction(value: TextFieldValue, action: RichTextAction): TextFieldValue = when (action) {
|
||||
RichTextAction.H1 -> wrapBlock(value, "h1")
|
||||
RichTextAction.H2 -> wrapBlock(value, "h2")
|
||||
RichTextAction.H3 -> wrapBlock(value, "h3")
|
||||
RichTextAction.Bold -> wrapInline(value, "strong")
|
||||
RichTextAction.Italic -> wrapInline(value, "em")
|
||||
RichTextAction.Underline -> wrapInline(value, "u")
|
||||
RichTextAction.Strike -> wrapInline(value, "s")
|
||||
RichTextAction.Color -> wrapInline(value, "span", " style=\"color: #dc2626;\"")
|
||||
RichTextAction.Background -> wrapInline(value, "span", " style=\"background-color: #fef3c7;\"")
|
||||
RichTextAction.OrderedList -> wrapLines(value, "ol")
|
||||
RichTextAction.BulletList -> wrapLines(value, "ul")
|
||||
RichTextAction.AlignCenter -> wrapSelection(value, """<p class="ql-align-center">""", "</p>")
|
||||
RichTextAction.Blockquote -> wrapBlock(value, "blockquote")
|
||||
RichTextAction.CodeBlock -> wrapSelection(value, """<pre class="ql-syntax" spellcheck="false">""", "</pre>")
|
||||
RichTextAction.Link,
|
||||
RichTextAction.Image,
|
||||
RichTextAction.Clean -> value
|
||||
}
|
||||
|
||||
private fun applyLink(value: TextFieldValue, url: String): TextFieldValue {
|
||||
val safeUrl = escapeHtml(url)
|
||||
val label = selectedText(value).ifBlank { safeUrl }
|
||||
return replaceSelection(value, """<a href="$safeUrl">$label</a>""")
|
||||
}
|
||||
|
||||
private fun wrapInline(value: TextFieldValue, tag: String, attrs: String = ""): TextFieldValue =
|
||||
wrapSelection(value, "<$tag$attrs>", "</$tag>")
|
||||
|
||||
private fun wrapBlock(value: TextFieldValue, tag: String): TextFieldValue =
|
||||
wrapSelection(value, "<$tag>", "</$tag>")
|
||||
|
||||
private fun wrapLines(value: TextFieldValue, listTag: String): TextFieldValue {
|
||||
val lines = selectedText(value).ifBlank { "Listeneintrag" }
|
||||
.lines()
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString("") { "<li>${escapeHtml(it)}</li>" }
|
||||
return replaceSelection(value, "<$listTag>$lines</$listTag>")
|
||||
}
|
||||
|
||||
private fun wrapSelection(value: TextFieldValue, prefix: String, suffix: String): TextFieldValue =
|
||||
replaceSelection(value, prefix + selectedText(value).ifBlank { "Text" } + suffix)
|
||||
|
||||
private fun insertHtml(value: TextFieldValue, html: String): TextFieldValue = replaceSelection(value, html)
|
||||
|
||||
private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue {
|
||||
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||
val next = value.text.replaceRange(start, end, replacement)
|
||||
val cursor = start + replacement.length
|
||||
return TextFieldValue(next, TextRange(cursor))
|
||||
}
|
||||
|
||||
private fun selectedText(value: TextFieldValue): String {
|
||||
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||
return value.text.substring(start, end)
|
||||
}
|
||||
|
||||
// HTML helper functions moved to RichTextUtils.kt for reuse and testing
|
||||
68
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Executable file
68
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Executable file
@@ -0,0 +1,68 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun PendingPage(
|
||||
navController: NavController,
|
||||
title: String,
|
||||
webPath: String,
|
||||
showBackNavigation: Boolean,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
|
||||
Text("Webseite: $webPath", color = Accent500)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text(
|
||||
"Die native Android-Seite wird in einem der nächsten Portierungsschritte umgesetzt.",
|
||||
color = Primary900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Executable file
31
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Executable file
@@ -0,0 +1,31 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.HtmlCompat
|
||||
|
||||
@Composable
|
||||
fun RichText(
|
||||
html: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
textSize = 17f
|
||||
setTextColor(android.graphics.Color.rgb(63, 63, 70))
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setLineSpacing(0f, 1.2f)
|
||||
}
|
||||
},
|
||||
update = { textView ->
|
||||
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
15
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Executable file
15
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Executable file
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
fun normalizeEmptyHtml(value: String): String =
|
||||
if (stripHtml(value).isBlank() && !value.contains("<img", ignoreCase = true)) "" else value
|
||||
|
||||
fun stripHtml(value: String): String = value
|
||||
.replace(Regex("<[^>]+>"), "")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
|
||||
fun escapeHtml(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
60
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Executable file
60
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Executable file
@@ -0,0 +1,60 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
sealed class Destinations(val route: String) {
|
||||
object Home : Destinations("home")
|
||||
object VereinAbout : Destinations("verein/about")
|
||||
object Vorstand : Destinations("verein/vorstand")
|
||||
object Geschichte : Destinations("verein/geschichte")
|
||||
object Satzung : Destinations("verein/satzung")
|
||||
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
|
||||
object Links : Destinations("verein/links")
|
||||
object Impressum : Destinations("impressum")
|
||||
object Mannschaften : Destinations("mannschaften")
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
|
||||
fun create(slug: String, season: String? = null): String {
|
||||
val encodedSlug = android.net.Uri.encode(slug)
|
||||
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
|
||||
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
|
||||
}
|
||||
}
|
||||
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||
fun create(slug: String): String = "mannschaft/$slug"
|
||||
}
|
||||
object Termine : Destinations("termine")
|
||||
object Spielplan : Destinations("spielplan")
|
||||
object Spielsysteme : Destinations("mannschaften/spielsysteme")
|
||||
object Training : Destinations("training")
|
||||
object Trainer : Destinations("training/trainer")
|
||||
object Anfaenger : Destinations("training/anfaenger")
|
||||
object Regeln : Destinations("training/regeln")
|
||||
object Gallery : Destinations("gallery")
|
||||
object NewsletterSubscribe : Destinations("newsletter/subscribe")
|
||||
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
|
||||
object NewsletterConfirm : Destinations("newsletter/confirm")
|
||||
object NewsletterConfirmed : Destinations("newsletter/confirmed")
|
||||
object NewsletterUnsubscribed : Destinations("newsletter/unsubscribed")
|
||||
object Contact : Destinations("contact")
|
||||
object Membership : Destinations("membership")
|
||||
object Login : Destinations("login")
|
||||
object PasswordReset : Destinations("passwordReset")
|
||||
object Register : Destinations("register")
|
||||
object MemberArea : Destinations("intern")
|
||||
object Members : Destinations("intern/mitglieder")
|
||||
object Qttr : Destinations("intern/qttr")
|
||||
object MemberNews : Destinations("intern/news")
|
||||
object Profile : Destinations("intern/profil")
|
||||
object NotificationSettings : Destinations("intern/benachrichtigungen")
|
||||
object MemberApi : Destinations("intern/api")
|
||||
object CmsStartseite : Destinations("cms/startseite")
|
||||
object CmsInhalte : Destinations("cms/inhalte")
|
||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||
object CmsNews : Destinations("cms/news")
|
||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||
object CmsNewsletter : Destinations("cms/newsletter")
|
||||
object CmsContactRequests : Destinations("cms/kontaktanfragen")
|
||||
object CmsEinstellungen : Destinations("cms/einstellungen")
|
||||
object CmsBenutzer : Destinations("cms/benutzer")
|
||||
object CmsPasswordResetDiagnostics : Destinations("cms/passwort-reset-diagnose")
|
||||
object Cms : Destinations("cms")
|
||||
}
|
||||
491
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Executable file
491
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Executable file
@@ -0,0 +1,491 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.LiveTickerBanner
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
navController: NavHostController,
|
||||
startDestination: String = Destinations.Home.route,
|
||||
navigationViewModelParam: NavigationViewModel? = null,
|
||||
) {
|
||||
val navigationViewModel: NavigationViewModel = navigationViewModelParam ?: hiltViewModel()
|
||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||
val route = backStackEntry?.destination?.route
|
||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||
backStackEntry.arguments?.getString("slug")?.let { slug ->
|
||||
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
|
||||
}
|
||||
} else route
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
LaunchedEffect(currentRoute) {
|
||||
navigationViewModel.refreshSession()
|
||||
}
|
||||
LaunchedEffect(navigationState.loggedIn) {
|
||||
navigationViewModel.setLiveTickerActive(navigationState.loggedIn)
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
val tabletNavigation = maxWidth >= 600.dp
|
||||
// Signed-in phones share the same menu and ticker above every screen.
|
||||
val persistentNavigation = tabletNavigation || navigationState.loggedIn
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
navigationState.connectionNote?.let { message ->
|
||||
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = message,
|
||||
color = Color(0xFF7C2D12),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (persistentNavigation) {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = currentRoute,
|
||||
onNavigate = navController::navigateTopLevel,
|
||||
onLogout = {
|
||||
navigationViewModel.logout {
|
||||
navController.navigate(Destinations.Home.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
webTabletNavigation = tabletNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
LiveTickerBanner(
|
||||
matches = navigationState.liveTickerMatches,
|
||||
loaded = navigationState.liveTickerLoaded,
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
composable(Destinations.Home.route) {
|
||||
de.harheimertc.ui.screens.home.HomeScreen(
|
||||
navController = navController,
|
||||
showNavigationHeader = !persistentNavigation,
|
||||
navigationViewModel = navigationViewModel,
|
||||
viewModel = hiltViewModel(),
|
||||
)
|
||||
}
|
||||
composable(Destinations.VereinAbout.route) {
|
||||
de.harheimertc.ui.screens.publicpages.AboutScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Vorstand.route) {
|
||||
de.harheimertc.ui.screens.publicpages.VorstandScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Geschichte.route) {
|
||||
de.harheimertc.ui.screens.publicpages.GeschichteScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Satzung.route) {
|
||||
de.harheimertc.ui.screens.publicpages.SatzungScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Vereinsmeisterschaften.route) {
|
||||
de.harheimertc.ui.screens.publicpages.VereinsmeisterschaftenScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Links.route) {
|
||||
de.harheimertc.ui.screens.publicpages.LinksScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Impressum.route) {
|
||||
de.harheimertc.ui.screens.publicpages.ImpressumScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Mannschaften.route) {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("mannschaften/herren") {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("mannschaften/damen") {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("mannschaften/jugend") {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(
|
||||
route = Destinations.MannschaftDetail.route,
|
||||
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
|
||||
) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = entry.arguments?.getString("season"),
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = null,
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Termine.route) {
|
||||
de.harheimertc.ui.screens.termine.TermineScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Spielplan.route) {
|
||||
de.harheimertc.ui.screens.spielplan.SpielplanScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Spielsysteme.route) {
|
||||
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("spielsysteme") {
|
||||
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Training.route) {
|
||||
de.harheimertc.ui.screens.training.TrainingScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Trainer.route) {
|
||||
de.harheimertc.ui.screens.training.TrainerScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Anfaenger.route) {
|
||||
de.harheimertc.ui.screens.training.AnfaengerScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Regeln.route) {
|
||||
de.harheimertc.ui.screens.publicpages.RegelnScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("tt-regeln") {
|
||||
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("verein/tt-regeln") {
|
||||
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Gallery.route) {
|
||||
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
||||
}
|
||||
composable("galerie") {
|
||||
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
||||
}
|
||||
composable(Destinations.NewsletterSubscribe.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterSubscribeScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterUnsubscribe.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribeScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterConfirm.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterConfirmScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
token = null,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterConfirmed.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterConfirmedScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NewsletterUnsubscribed.route) {
|
||||
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribedScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Contact.route) {
|
||||
de.harheimertc.ui.screens.contact.ContactScreen()
|
||||
}
|
||||
composable(Destinations.Membership.route) {
|
||||
de.harheimertc.ui.screens.membership.MembershipScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Login.route) {
|
||||
de.harheimertc.ui.screens.login.LoginScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.PasswordReset.route) {
|
||||
de.harheimertc.ui.screens.login.PasswordResetScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Register.route) {
|
||||
de.harheimertc.ui.screens.login.RegisterScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable("ueber-uns") {
|
||||
de.harheimertc.ui.screens.publicpages.AboutScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("geschichte") {
|
||||
de.harheimertc.ui.screens.publicpages.GeschichteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable("satzung") {
|
||||
de.harheimertc.ui.screens.publicpages.SatzungScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.MemberArea.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Members.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MembersScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Qttr.route) {
|
||||
de.harheimertc.ui.screens.memberarea.QttrScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberNews.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Profile.route) {
|
||||
de.harheimertc.ui.screens.profile.ProfileScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NotificationSettings.route) {
|
||||
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberApi.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.CmsStartseite.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsInhalte.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||
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) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsMitgliederverwaltung.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsNewsletter.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessNewsletter,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsContactRequests.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessContactRequests,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsEinstellungen.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsBenutzer.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
composable(Destinations.Cms.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequireCmsAccess(
|
||||
allowed: Boolean,
|
||||
navController: NavHostController,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (allowed) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Kein Zugriff auf diesen Bereich.",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = Color(0xFF7F1D1D),
|
||||
)
|
||||
Text(
|
||||
text = "Bitte mit einem berechtigten Konto anmelden.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF7C2D12),
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||
)
|
||||
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
|
||||
Text("Zum Login")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavHostController.navigateTopLevel(route: String) {
|
||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||
route != Destinations.Spielsysteme.route
|
||||
navigate(route) {
|
||||
launchSingleTop = !isTeamDetail
|
||||
restoreState = !isTeamDetail
|
||||
popUpTo(Destinations.Home.route) {
|
||||
saveState = !isTeamDetail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.LiveTickerRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NavigationUiState(
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
val hasGalleryImages: Boolean = false,
|
||||
val loggedIn: Boolean = false,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val connectionNote: String? = null,
|
||||
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
|
||||
val liveTickerLoaded: Boolean = false,
|
||||
) {
|
||||
val isAdmin: Boolean get() = "admin" in roles
|
||||
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
|
||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class NavigationViewModel @Inject constructor(
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val galleryRepository: GalleryRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
private val liveTickerRepository: LiveTickerRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
private var tickerJob: Job? = null
|
||||
private var tickerActive = false
|
||||
|
||||
init {
|
||||
loadNavigationData()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
_state.value = _state.value.copy(
|
||||
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
|
||||
)
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNavigationData() {
|
||||
viewModelScope.launch {
|
||||
val teams = async { mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()) }
|
||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||
val status = auth.await()
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = NavigationUiState(
|
||||
teams = teams.await(),
|
||||
hasGalleryImages = gallery.await(),
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = null,
|
||||
liveTickerMatches = _state.value.liveTickerMatches,
|
||||
liveTickerLoaded = _state.value.liveTickerLoaded,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSession() {
|
||||
viewModelScope.launch {
|
||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = _state.value.connectionNote,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
fun setLiveTickerActive(active: Boolean) {
|
||||
if (tickerActive == active) return
|
||||
tickerActive = active
|
||||
tickerJob?.cancel()
|
||||
if (!active) {
|
||||
_state.value = _state.value.copy(liveTickerMatches = emptyList(), liveTickerLoaded = false)
|
||||
return
|
||||
}
|
||||
tickerJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
liveTickerRepository.fetch().onSuccess { matches ->
|
||||
_state.value = _state.value.copy(liveTickerMatches = matches, liveTickerLoaded = true)
|
||||
}
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerPushToken() {
|
||||
viewModelScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(onComplete: () -> Unit = {}) {
|
||||
setLiveTickerActive(false)
|
||||
viewModelScope.launch {
|
||||
loginRepository.logout()
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = false,
|
||||
roles = emptySet(),
|
||||
connectionNote = _state.value.connectionNote,
|
||||
)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
|
||||
addAll(roles)
|
||||
role?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
addAll(user?.roles.orEmpty())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
// Placeholder: functionality moved to CmsScreens.kt (CmsUserListPage / UserCard)
|
||||
320
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Executable file
320
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Executable file
@@ -0,0 +1,320 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var selection by remember { mutableStateOf(setOf<String>()) }
|
||||
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||
val loginState by loginVm.state.collectAsState()
|
||||
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||
val context = LocalContext.current
|
||||
var showSuccessDialog by remember { mutableStateOf(false) }
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(state.message) {
|
||||
if (!state.message.isNullOrBlank()) showSuccessDialog = true
|
||||
}
|
||||
|
||||
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||
var dialogOpen by remember { mutableStateOf(false) }
|
||||
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
|
||||
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var content by remember { mutableStateOf("") }
|
||||
var isPublic by remember { mutableStateOf(false) }
|
||||
var isHidden by remember { mutableStateOf(false) }
|
||||
var expiresAt by remember { mutableStateOf("") } // format: yyyy-MM-dd'T'HH:mm
|
||||
|
||||
val dtFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
|
||||
val displayFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy, HH:mm", Locale.GERMANY)
|
||||
|
||||
fun convertUTCToLocal(utc: String?): String {
|
||||
if (utc.isNullOrBlank()) return ""
|
||||
return try {
|
||||
val instant = Instant.parse(utc)
|
||||
LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).format(dtFormatter)
|
||||
} catch (e: Exception) { "" }
|
||||
}
|
||||
|
||||
fun convertLocalToUTC(local: String?): String? {
|
||||
if (local.isNullOrBlank()) return null
|
||||
return try {
|
||||
val ldt = LocalDateTime.parse(local, dtFormatter)
|
||||
ldt.atZone(ZoneId.systemDefault()).toInstant().toString()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
// open create
|
||||
fun openAdd() {
|
||||
editing = null
|
||||
title = ""
|
||||
content = ""
|
||||
isPublic = false
|
||||
isHidden = false
|
||||
expiresAt = ""
|
||||
dialogOpen = true
|
||||
}
|
||||
|
||||
// open edit
|
||||
fun openEdit(item: NewsDto) {
|
||||
editing = item
|
||||
title = item.title
|
||||
content = item.content
|
||||
isPublic = item.isPublic
|
||||
isHidden = item.isHidden
|
||||
expiresAt = convertUTCToLocal(item.expiresAt)
|
||||
dialogOpen = true
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||
if (state.loading) item { LoadingState("News werden geladen...") }
|
||||
|
||||
item {
|
||||
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||
}
|
||||
|
||||
item {
|
||||
if (canWrite) Button(onClick = { openAdd() }, modifier = Modifier.fillMaxWidth()) { Text("News erstellen") }
|
||||
}
|
||||
|
||||
item {
|
||||
FormMessages(state.error, state.message)
|
||||
}
|
||||
|
||||
if (!state.loading && state.news.isEmpty()) item { Text("Noch keine News vorhanden.", modifier = Modifier.padding(12.dp)) }
|
||||
|
||||
// selection state for bulk actions (moved to outer scope)
|
||||
|
||||
items(state.news) { news ->
|
||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||
NewsListItem(
|
||||
news = news,
|
||||
canWrite = canWrite,
|
||||
selected = selected,
|
||||
onSelect = { id, sel ->
|
||||
id?.let {
|
||||
selection = if (sel) selection + it else selection - it
|
||||
}
|
||||
},
|
||||
onEdit = { openEdit(news) },
|
||||
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
|
||||
)
|
||||
}
|
||||
|
||||
// bulk action bar
|
||||
if (canWrite && selection.isNotEmpty()) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), false) }) { Text("Als nicht-öffentlich markieren") }
|
||||
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), true) }) { Text("Ausblenden") }
|
||||
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), false) }) { Text("Einblenden") }
|
||||
Button(onClick = { /* confirm then delete */ deletingIds = selection.toList() }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (moved earlier)
|
||||
|
||||
// delete confirmation dialog
|
||||
if (deletingIds != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { deletingIds = null },
|
||||
title = { Text("News löschen") },
|
||||
text = { Text("Möchten Sie die ausgewählten News wirklich löschen?") },
|
||||
confirmButton = { Button(onClick = {
|
||||
deletingIds?.let { viewModel.bulkDelete(it) }
|
||||
deletingIds = null
|
||||
selection = emptySet()
|
||||
}) { Text("Löschen") } },
|
||||
dismissButton = { TextButton(onClick = { deletingIds = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
// dialog for create/edit
|
||||
if (dialogOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogOpen = false },
|
||||
title = { Text(if (editing == null) "News erstellen" else "News bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Titel *") }, modifier = Modifier.fillMaxWidth())
|
||||
NativeRichTextEditor(content, { content = it }, "Inhalt *")
|
||||
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it })
|
||||
Text("Öffentliche News (auf Startseite anzeigen)", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
// read-only datetime field that opens native pickers
|
||||
OutlinedTextField(
|
||||
value = expiresAt,
|
||||
onValueChange = { /* no-op: controlled by pickers */ },
|
||||
label = { Text("Ablaufdatum (optional)") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
// open date then time picker
|
||||
val now = java.util.Calendar.getInstance()
|
||||
val year = now.get(java.util.Calendar.YEAR)
|
||||
val month = now.get(java.util.Calendar.MONTH)
|
||||
val day = now.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
android.app.DatePickerDialog(context, { _, y, m, d ->
|
||||
val hour = now.get(java.util.Calendar.HOUR_OF_DAY)
|
||||
val minute = now.get(java.util.Calendar.MINUTE)
|
||||
android.app.TimePickerDialog(context, { _, h, min ->
|
||||
val ldt = LocalDateTime.of(y, m + 1, d, h, min)
|
||||
expiresAt = ldt.format(dtFormatter)
|
||||
}, hour, minute, true).show()
|
||||
}, year, month, day).show()
|
||||
},
|
||||
readOnly = true,
|
||||
)
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = isHidden, onCheckedChange = { isHidden = it })
|
||||
Text("News ausblenden", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
val err = state.error
|
||||
if (err != null) {
|
||||
Text(err, color = Color(0xFF842029))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
val req = NewsSaveRequest(
|
||||
id = editing?.id,
|
||||
title = title,
|
||||
content = content,
|
||||
isPublic = isPublic,
|
||||
isHidden = isHidden,
|
||||
expiresAt = convertLocalToUTC(expiresAt),
|
||||
)
|
||||
viewModel.saveNews(req)
|
||||
dialogOpen = false
|
||||
}, enabled = !state.saving) { Text(if (state.saving) "Speichert..." else "Speichern") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dialogOpen = false }) { Text("Abbrechen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSuccessDialog && !state.message.isNullOrBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSuccessDialog = false },
|
||||
title = { Text("Erfolg") },
|
||||
text = { Text(state.message ?: "") },
|
||||
confirmButton = { Button(onClick = { showSuccessDialog = false }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsListItem(
|
||||
news: NewsDto,
|
||||
canWrite: Boolean,
|
||||
selected: Boolean = false,
|
||||
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||
onEdit: (NewsDto) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onSelect(news.id, it) },
|
||||
enabled = canWrite,
|
||||
)
|
||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||
if (news.isPublic) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF0EA5A6)))
|
||||
Text("Öffentlich", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
if (news.isHidden) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color.Gray))
|
||||
Text("Ausgeblendet", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
val expired = news.expiresAt?.let {
|
||||
try { Instant.parse(it).isBefore(Instant.now()) || Instant.parse(it).equals(Instant.now()) } catch (e: Exception) { false }
|
||||
} ?: false
|
||||
if (expired) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFB91C1C)))
|
||||
Text("Abgelaufen", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(news.author ?: "-", modifier = Modifier.padding(end = 12.dp))
|
||||
Text(news.created ?: "-")
|
||||
}
|
||||
if (news.updated != null && news.updated != news.created) {
|
||||
Text("Aktualisiert: ${news.updated}")
|
||||
}
|
||||
}
|
||||
if (canWrite) {
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1867
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Executable file
1867
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Executable file
File diff suppressed because it is too large
Load Diff
670
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Executable file
670
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Executable file
@@ -0,0 +1,670 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsletterDto
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.CmsMannschaftRow
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import de.harheimertc.ui.util.ErrorMapper
|
||||
|
||||
data class CmsUiState(
|
||||
val loading: Boolean = true,
|
||||
val saving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
val config: ConfigResponse? = null,
|
||||
val users: List<CmsUserDto> = emptyList(),
|
||||
val contactRequests: List<ContactRequestDto> = emptyList(),
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
|
||||
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
val passwordResetMatchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
|
||||
val passwordResetRetentionHours: Int = 72,
|
||||
val passwordResetSearchTerm: String = "",
|
||||
val passwordResetFailedOnly: Boolean = true,
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||
val sportLoading: Boolean = false,
|
||||
val sportSaving: Boolean = false,
|
||||
val sportTermine: List<TerminDto> = emptyList(),
|
||||
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
|
||||
val sportMannschaftenSeasons: List<String> = emptyList(),
|
||||
val sportMannschaftenSeason: String = "",
|
||||
val sportSpielplanHeaders: List<String> = emptyList(),
|
||||
val sportSpielplanRows: List<List<String>> = emptyList(),
|
||||
val sportSpielplanSeason: String = "",
|
||||
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CmsViewModel @Inject constructor(
|
||||
private val repository: CmsRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(CmsUiState())
|
||||
val state: StateFlow<CmsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
|
||||
val configRes = async { repository.config() }
|
||||
val usersRes = async { repository.users() }
|
||||
val requestsRes = async { repository.contactRequests() }
|
||||
val newslettersRes = async { repository.newsletters() }
|
||||
val groupsRes = async { repository.newsletterGroups() }
|
||||
val newsRes = async { repository.news() }
|
||||
val diagnosticsRes = async {
|
||||
repository.passwordResetDiagnostics(
|
||||
email = _state.value.passwordResetSearchTerm.takeIf { it.isNotBlank() },
|
||||
failedOnly = _state.value.passwordResetFailedOnly,
|
||||
)
|
||||
}
|
||||
val meisterschaftenRes = async { repository.vereinsmeisterschaften() }
|
||||
|
||||
val configResult = configRes.await()
|
||||
val usersResult = usersRes.await()
|
||||
val requestsResult = requestsRes.await()
|
||||
val newslettersResult = newslettersRes.await()
|
||||
val groupsResult = groupsRes.await()
|
||||
val newsResult = newsRes.await()
|
||||
val diagnosticsResult = diagnosticsRes.await()
|
||||
val meisterschaftenResult = meisterschaftenRes.await()
|
||||
|
||||
val errors = listOfNotNull(
|
||||
ErrorMapper.mapError(configResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(usersResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(requestsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(newslettersResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(groupsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(newsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(meisterschaftenResult.exceptionOrNull()),
|
||||
)
|
||||
|
||||
// Sort users so that pending (inactive) users come first,
|
||||
// followed by active users sorted by display name (case-insensitive).
|
||||
val fetchedUsers = usersResult.getOrNull()?.users.orEmpty()
|
||||
val pendingUsers = fetchedUsers.filter { it.active == false }
|
||||
val activeUsers = fetchedUsers.filter { it.active == true }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
val orderedUsers = pendingUsers + activeUsers
|
||||
|
||||
_state.value = CmsUiState(
|
||||
loading = false,
|
||||
error = if (errors.isNotEmpty()) errors.joinToString("; ") else null,
|
||||
config = configResult.getOrNull(),
|
||||
users = orderedUsers,
|
||||
contactRequests = requestsResult.getOrNull().orEmpty(),
|
||||
newsletters = newslettersResult.getOrNull()?.newsletters.orEmpty(),
|
||||
newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
|
||||
news = newsResult.getOrNull()?.news.orEmpty(),
|
||||
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
|
||||
passwordResetMatchingUsers = diagnosticsResult.getOrNull()?.matchingUsers.orEmpty(),
|
||||
passwordResetRetentionHours = diagnosticsResult.getOrNull()?.retentionHours ?: 72,
|
||||
passwordResetSearchTerm = diagnosticsResult.getOrNull()?.searchedEmail.orEmpty(),
|
||||
passwordResetFailedOnly = _state.value.passwordResetFailedOnly,
|
||||
meisterschaften = meisterschaftenResult.getOrNull().orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPasswordResetDiagnostics(email: String, failedOnly: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
error = null,
|
||||
passwordResetSearchTerm = email.trim(),
|
||||
passwordResetFailedOnly = failedOnly,
|
||||
)
|
||||
repository.passwordResetDiagnostics(
|
||||
email = email.trim().takeIf { it.isNotBlank() },
|
||||
failedOnly = failedOnly,
|
||||
)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
passwordResetRetentionHours = response.retentionHours,
|
||||
passwordResetMatchingUsers = response.matchingUsers,
|
||||
passwordResetAttempts = response.attempts,
|
||||
passwordResetSearchTerm = response.searchedEmail.orEmpty(),
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
passwordResetMatchingUsers = emptyList(),
|
||||
passwordResetAttempts = emptyList(),
|
||||
error = ErrorMapper.mapError(err) ?: "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.saveVereinsmeisterschaften(results)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
meisterschaften = results,
|
||||
message = response.message ?: "Vereinsmeisterschaften gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Vereinsmeisterschaften konnten nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSportbetrieb() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
|
||||
|
||||
val termineRes = async { repository.managedTermine() }
|
||||
val seasonsRes = async { repository.mannschaftenSeasons() }
|
||||
val spielplanRes = async { repository.spielplan() }
|
||||
|
||||
val termineResult = termineRes.await()
|
||||
val seasonsResult = seasonsRes.await()
|
||||
val seasonInfo = seasonsResult.getOrNull()
|
||||
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
|
||||
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
|
||||
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
|
||||
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
|
||||
val spielplanResult = spielplanRes.await()
|
||||
val errors = listOfNotNull(
|
||||
ErrorMapper.mapError(termineResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
|
||||
)
|
||||
val spielplan = spielplanResult.getOrNull()
|
||||
val headers = spielplan?.headers.orEmpty()
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
sportLoading = false,
|
||||
sportTermine = termineResult.getOrNull().orEmpty(),
|
||||
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
|
||||
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
|
||||
sportMannschaftenSeason = selectedSeason,
|
||||
sportSpielplanHeaders = headers,
|
||||
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
|
||||
sportSpielplanSeason = spielplan?.season.orEmpty(),
|
||||
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
|
||||
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSportMannschaftenSeason(season: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
|
||||
repository.mannschaften(season)
|
||||
.onSuccess { rows ->
|
||||
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportLoading = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
if (original != null) {
|
||||
repository.deleteTermin(original)
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
val saveResult = repository.saveTermin(termin)
|
||||
saveResult
|
||||
.onSuccess { response ->
|
||||
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportTermine = termine,
|
||||
message = response.message ?: "Termin gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSportTermin(termin: TerminDto) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
repository.deleteTermin(termin)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportTermine = _state.value.sportTermine.filterNot { it == termin },
|
||||
message = response.message ?: "Termin gelöscht.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportMannschaften = rows,
|
||||
message = response.message ?: "Mannschaften gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importSportSpielplan() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
repository.importSpielplan()
|
||||
.onSuccess { response ->
|
||||
if (!response.success) {
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = response.message ?: "Spielplan konnte nicht importiert werden.",
|
||||
)
|
||||
return@onSuccess
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
message = response.message ?: "Spielplan aktualisiert.",
|
||||
)
|
||||
loadSportbetrieb()
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
repository.saveSpielplan(headers, rows)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportSpielplanHeaders = headers,
|
||||
sportSpielplanRows = rows,
|
||||
message = response.message ?: "Spielplan gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig(config: ConfigResponse) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.saveConfig(config)
|
||||
.onSuccess { saved ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
config = saved,
|
||||
message = "Inhalt gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
error = ErrorMapper.mapError(it) ?: "Inhalt konnte nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveNews(request: NewsSaveRequest) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.saveNews(request)
|
||||
.onSuccess { msg ->
|
||||
// refresh news list directly to preserve message
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
news = newsRes.getOrNull()?.news.orEmpty(),
|
||||
message = msg.message ?: "Nachricht gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
val existing = _state.value.news.find { it.id == id }
|
||||
if (existing != null) {
|
||||
val req = NewsSaveRequest(
|
||||
id = existing.id,
|
||||
title = existing.title,
|
||||
content = existing.content,
|
||||
isPublic = makePublic,
|
||||
isHidden = existing.isHidden,
|
||||
expiresAt = existing.expiresAt,
|
||||
)
|
||||
repository.saveNews(req)
|
||||
}
|
||||
}
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
val existing = _state.value.news.find { it.id == id }
|
||||
if (existing != null) {
|
||||
val req = NewsSaveRequest(
|
||||
id = existing.id,
|
||||
title = existing.title,
|
||||
content = existing.content,
|
||||
isPublic = existing.isPublic,
|
||||
isHidden = makeHidden,
|
||||
expiresAt = existing.expiresAt,
|
||||
)
|
||||
repository.saveNews(req)
|
||||
}
|
||||
}
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkDelete(ids: List<String>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
repository.deleteNews(id)
|
||||
}
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Löschung abgeschlossen.")
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNews(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNews(id)
|
||||
.onSuccess { msg ->
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
news = newsRes.getOrNull()?.news.orEmpty(),
|
||||
message = msg.message ?: "Nachricht gelöscht.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gelöscht werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Newsletter (B4)
|
||||
fun saveNewsletter(request: de.harheimertc.data.NewsletterCreateRequest) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.createNewsletter(request)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(),
|
||||
message = res.message ?: "Newsletter gespeichert",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNewsletter(id: String, patch: Map<String, Any?>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateNewsletter(id, patch)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter aktualisiert")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht aktualisiert werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNewsletter(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.sendNewsletter(id)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter versendet")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht versendet werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNewsletter(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNewsletter(id)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter gelöscht")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gelöscht werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Newsletter Groups (B4)
|
||||
fun createNewsletterGroup(payload: Map<String, Any?>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.createNewsletterGroup(payload)
|
||||
.onSuccess { res ->
|
||||
val groupsRes = repository.newsletterGroups()
|
||||
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe erstellt")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht erstellt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNewsletterGroup(id: String, patch: Map<String, Any?>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateNewsletterGroup(id, patch)
|
||||
.onSuccess { res ->
|
||||
val groupsRes = repository.newsletterGroups()
|
||||
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe aktualisiert")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht aktualisiert werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNewsletterGroup(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNewsletterGroup(id)
|
||||
.onSuccess { res ->
|
||||
val groupsRes = repository.newsletterGroups()
|
||||
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe gelöscht")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht gelöscht werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- User management actions (B2)
|
||||
fun updateUserRoles(id: String, roles: List<String>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateUserRoles(id, roles)
|
||||
.onSuccess { msg ->
|
||||
val usersRes = repository.users()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
users = usersRes.getOrNull()?.users.orEmpty(),
|
||||
message = msg.message ?: "Rollen aktualisiert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Rollen konnten nicht aktualisiert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setUserActive(id: String, active: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateUserActive(id, active)
|
||||
.onSuccess { msg ->
|
||||
val usersRes = repository.users()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
users = usersRes.getOrNull()?.users.orEmpty(),
|
||||
message = msg.message ?: if (active) "Benutzer aktiviert." else "Benutzer deaktiviert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Benutzerstatus konnte nicht geändert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resendInvite(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.resendInvite(id)
|
||||
.onSuccess { msg ->
|
||||
_state.value = _state.value.copy(saving = false, message = msg.message ?: "Einladung erneut gesendet.")
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Einladung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contact requests (B3)
|
||||
fun replyToContactRequest(id: String, message: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.replyToContactRequest(id, message)
|
||||
.onSuccess {
|
||||
val reqs = repository.contactRequests()
|
||||
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Antwort gesendet.")
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Antwort konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleContactRequestStatus(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.toggleContactRequestStatus(id)
|
||||
.onSuccess {
|
||||
val reqs = repository.contactRequests()
|
||||
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Status aktualisiert.")
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Status konnte nicht geändert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,0 +1,46 @@
|
||||
package de.harheimertc.ui.screens.contact
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
|
||||
@Composable
|
||||
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
||||
val name by viewModel.name.collectAsState()
|
||||
val email by viewModel.email.collectAsState()
|
||||
val message by viewModel.message.collectAsState()
|
||||
val sending by viewModel.sending.collectAsState()
|
||||
val result by viewModel.result.collectAsState()
|
||||
val fieldErrors by viewModel.fieldErrors.collectAsState()
|
||||
|
||||
Surface(modifier = Modifier.padding(16.dp)) {
|
||||
Column {
|
||||
ValidatedTextField(name, viewModel::onName, "Name", error = fieldErrors["name"])
|
||||
ValidatedTextField(email, viewModel::onEmail, "E-Mail", error = fieldErrors["email"])
|
||||
ValidatedTextField(
|
||||
value = message,
|
||||
onValueChange = viewModel::onMessage,
|
||||
label = "Nachricht",
|
||||
error = fieldErrors["message"],
|
||||
singleLine = false,
|
||||
minLines = 4,
|
||||
)
|
||||
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(if (sending) "Sende…" else "Absenden")
|
||||
}
|
||||
if (result != null) {
|
||||
Text(text = result!!, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.harheimertc.ui.screens.contact
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.repositories.ContactRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ContactViewModel @Inject constructor(private val repo: ContactRepository) : ViewModel() {
|
||||
private val _name = MutableStateFlow("")
|
||||
val name: StateFlow<String> = _name
|
||||
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _email
|
||||
|
||||
private val _message = MutableStateFlow("")
|
||||
val message: StateFlow<String> = _message
|
||||
|
||||
private val _sending = MutableStateFlow(false)
|
||||
val sending: StateFlow<Boolean> = _sending
|
||||
|
||||
private val _result = MutableStateFlow<String?>(null)
|
||||
val result: StateFlow<String?> = _result
|
||||
|
||||
private val _fieldErrors = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val fieldErrors: StateFlow<Map<String, String>> = _fieldErrors
|
||||
|
||||
fun onName(v: String) {
|
||||
_name.value = v
|
||||
clearFieldError("name")
|
||||
}
|
||||
|
||||
fun onEmail(v: String) {
|
||||
_email.value = v
|
||||
clearFieldError("email")
|
||||
}
|
||||
|
||||
fun onMessage(v: String) {
|
||||
_message.value = v
|
||||
clearFieldError("message")
|
||||
}
|
||||
|
||||
fun send() {
|
||||
val n = _name.value.trim()
|
||||
val e = _email.value.trim()
|
||||
val m = _message.value.trim()
|
||||
val errors = buildMap {
|
||||
if (n.isEmpty()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||
if (e.isEmpty()) put("email", "Bitte geben Sie Ihre E-Mail-Adresse ein.")
|
||||
else if (!isValidEmail(e)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (m.isEmpty()) put("message", "Bitte geben Sie eine Nachricht ein.")
|
||||
else if (m.length < 10) put("message", "Die Nachricht sollte mindestens 10 Zeichen haben.")
|
||||
}
|
||||
if (errors.isNotEmpty()) {
|
||||
_fieldErrors.value = errors
|
||||
_result.value = "Bitte prüfen Sie die markierten Felder."
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_sending.value = true
|
||||
try {
|
||||
val resp = repo.sendContact(ContactRequest(n, e, m))
|
||||
if (resp.isSuccessful) {
|
||||
_result.value = "Nachricht gesendet"
|
||||
_fieldErrors.value = emptyMap()
|
||||
_name.value = ""
|
||||
_email.value = ""
|
||||
_message.value = ""
|
||||
} else {
|
||||
_result.value = "Fehler: ${resp.code()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_result.value = "Netzwerkfehler"
|
||||
} finally {
|
||||
_sending.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearFieldError(field: String) {
|
||||
if (_fieldErrors.value.containsKey(field)) {
|
||||
_fieldErrors.value = _fieldErrors.value - field
|
||||
}
|
||||
_result.value = null
|
||||
}
|
||||
}
|
||||
146
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Executable file
146
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Executable file
@@ -0,0 +1,146 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.ImageGrid
|
||||
|
||||
@Composable
|
||||
fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
||||
val images by viewModel.images.collectAsState()
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
val uploading by viewModel.uploading.collectAsState()
|
||||
val message by viewModel.message.collectAsState()
|
||||
val canUpload by viewModel.canUpload.collectAsState()
|
||||
var selectedUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var isPublic by remember { mutableStateOf(false) }
|
||||
var showUpload by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
selectedUri = uri
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.gallery_title),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (canUpload) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.gallery_upload_title), modifier = Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { showUpload = !showUpload }) {
|
||||
Text(if (showUpload) stringResource(R.string.gallery_upload_hide) else stringResource(R.string.gallery_upload_show))
|
||||
}
|
||||
}
|
||||
if (showUpload) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = { picker.launch("image/*") },
|
||||
enabled = !uploading,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = context.getString(R.string.gallery_upload_choose_file)
|
||||
},
|
||||
) {
|
||||
Text(selectedUri?.lastPathSegment ?: stringResource(R.string.gallery_upload_choose_file))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text(stringResource(R.string.gallery_upload_image_title)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uploading,
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text(stringResource(R.string.gallery_upload_description)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uploading,
|
||||
minLines = 2,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it }, enabled = !uploading)
|
||||
Text(stringResource(R.string.gallery_upload_public))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
selectedUri?.let { uri ->
|
||||
viewModel.upload(uri, title, description, isPublic)
|
||||
}
|
||||
},
|
||||
enabled = selectedUri != null && title.isNotBlank() && !uploading,
|
||||
) {
|
||||
Text(if (uploading) stringResource(R.string.gallery_uploading) else stringResource(R.string.gallery_upload_submit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
FormMessages(error = error, message = message)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
if (loading) {
|
||||
LoadingState("Galerie wird geladen...")
|
||||
} else if (images.isEmpty()) {
|
||||
Text(text = stringResource(R.string.gallery_empty))
|
||||
} else {
|
||||
ImageGrid(images = images, modifier = Modifier.height(520.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
viewModel.load()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import android.net.Uri
|
||||
import de.harheimertc.repositories.GalleryImage
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class GalleryViewModel @Inject constructor(
|
||||
private val repo: GalleryRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
) : ViewModel() {
|
||||
private val _images = MutableStateFlow<List<GalleryImage>>(emptyList())
|
||||
val images: StateFlow<List<GalleryImage>> = _images
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
private val _uploading = MutableStateFlow(false)
|
||||
val uploading: StateFlow<Boolean> = _uploading
|
||||
|
||||
private val _message = MutableStateFlow<String?>(null)
|
||||
val message: StateFlow<String?> = _message
|
||||
|
||||
private val _canUpload = MutableStateFlow(false)
|
||||
val canUpload: StateFlow<Boolean> = _canUpload
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
_error.value = null
|
||||
repo.fetchImages()
|
||||
.onSuccess { _images.value = it.images }
|
||||
.onFailure { _error.value = it.message ?: "Fehler" }
|
||||
loginRepository.status()
|
||||
.onSuccess { status ->
|
||||
val roles = (status.roles + status.user?.roles.orEmpty() + listOfNotNull(status.role)).toSet()
|
||||
_canUpload.value = roles.any { it in setOf("admin", "vorstand") }
|
||||
}
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(uri: Uri, title: String, description: String, isPublic: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_uploading.value = true
|
||||
_error.value = null
|
||||
_message.value = null
|
||||
repo.uploadImage(uri, title, description, isPublic)
|
||||
.onSuccess {
|
||||
_message.value = "Bild erfolgreich hochgeladen."
|
||||
load()
|
||||
}
|
||||
.onFailure { _error.value = it.message ?: "Fehler beim Hochladen des Bildes" }
|
||||
_uploading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
834
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt
Executable file
834
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt
Executable file
@@ -0,0 +1,834 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
navController: NavController,
|
||||
showNavigationHeader: Boolean = true,
|
||||
navigationViewModel: NavigationViewModel,
|
||||
viewModel: HomeViewModel,
|
||||
) {
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||
var editHomeSections by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
selectedNews?.let { item ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectedNews = null },
|
||||
title = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
text = { RichText(item.content) },
|
||||
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color.White),
|
||||
) {
|
||||
if (showNavigationHeader) {
|
||||
item {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = Destinations.Home.route,
|
||||
onNavigate = navController::navigate,
|
||||
onLogout = {
|
||||
navigationViewModel.logout {
|
||||
navController.navigate(Destinations.Home.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (navigationState.canAccessFullCms) {
|
||||
item {
|
||||
HomeCustomizationSection(
|
||||
sections = state.homepageSections,
|
||||
spielplanSeasons = state.spielplanSeasons,
|
||||
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
||||
editEnabled = editHomeSections,
|
||||
onToggleEdit = { editHomeSections = !editHomeSections },
|
||||
onMoveUp = viewModel::moveSectionUp,
|
||||
onMoveDown = viewModel::moveSectionDown,
|
||||
onEnabledChange = viewModel::setSectionEnabled,
|
||||
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
||||
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
||||
onReset = viewModel::resetSections,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.error) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(onClick = viewModel::load) {
|
||||
Text("Erneut versuchen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.debugDiagnostics.isNotEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Technische Diagnose (vorübergehend)",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
state.homepageSections.forEachIndexed { index, section ->
|
||||
if (!section.enabled) return@forEachIndexed
|
||||
val sectionKey = homeSectionKey(section)
|
||||
when (section.id) {
|
||||
"banner" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
WebHero(imageUrl = state.heroImageUrl)
|
||||
}
|
||||
"termine" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeTermineSection(
|
||||
termine = state.termine,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Termine.route) },
|
||||
)
|
||||
}
|
||||
"spiele" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeGamesSection(
|
||||
spiele = state.spiele,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||
)
|
||||
}
|
||||
"aktuelles" -> {
|
||||
if (state.news.isNotEmpty()) {
|
||||
item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeNewsSection(
|
||||
news = state.news,
|
||||
onOpen = { selectedNews = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
"kontakt" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeActionSection(
|
||||
onMembership = { navController.navigate(Destinations.Membership.route) },
|
||||
onContact = { navController.navigate(Destinations.Contact.route) },
|
||||
)
|
||||
}
|
||||
"training" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeExtraActionSection(
|
||||
title = "Training & Einstieg",
|
||||
body = "Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot.",
|
||||
action = "Zum Training",
|
||||
onClick = { navController.navigate(Destinations.Training.route) },
|
||||
)
|
||||
}
|
||||
"links" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeExtraActionSection(
|
||||
title = "Nützliche Links",
|
||||
body = "Direkter Zugang zu Verbänden, Ergebnisdiensten und hilfreichen Portalen.",
|
||||
action = "Links öffnen",
|
||||
onClick = { navController.navigate(Destinations.Links.route) },
|
||||
)
|
||||
}
|
||||
"vereinsmeisterschaften" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeExtraActionSection(
|
||||
title = "Vereinsmeisterschaften",
|
||||
body = "Ergebnisse und Historie unserer Vereinsmeisterschaften.",
|
||||
action = "Ergebnisse ansehen",
|
||||
onClick = { navController.navigate(Destinations.Vereinsmeisterschaften.route) },
|
||||
)
|
||||
}
|
||||
"spielplan_team" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeSpielplanTeamWidgetSection(
|
||||
section = section,
|
||||
spiele = state.spielplanWidgetPreviews[sectionKey].orEmpty(),
|
||||
error = state.spielplanWidgetErrors[sectionKey],
|
||||
loading = state.widgetsLoading,
|
||||
onOpenAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HomeFooter() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeCustomizationSection(
|
||||
sections: List<HomepageSectionDto>,
|
||||
spielplanSeasons: List<SeasonDto>,
|
||||
spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||
editEnabled: Boolean,
|
||||
onToggleEdit: () -> Unit,
|
||||
onMoveUp: (String) -> Unit,
|
||||
onMoveDown: (String) -> Unit,
|
||||
onEnabledChange: (String, Boolean) -> Unit,
|
||||
onAddSpielplanWidget: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||
onUpdateSpielplanWidget: (sectionKey: String, season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
) {
|
||||
var addSeason by rememberSaveable { mutableStateOf("") }
|
||||
var addTeamKey by rememberSaveable { mutableStateOf("") }
|
||||
val addTeamOptions = spielplanTeamsBySeason[addSeason].orEmpty()
|
||||
|
||||
LaunchedEffect(spielplanSeasons) {
|
||||
if (addSeason.isBlank() || spielplanSeasons.none { it.slug == addSeason }) {
|
||||
addSeason = spielplanSeasons.firstOrNull()?.slug.orEmpty()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(addSeason, addTeamOptions) {
|
||||
if (addTeamOptions.none { teamOptionKey(it) == addTeamKey }) {
|
||||
addTeamKey = addTeamOptions.firstOrNull()?.let(::teamOptionKey).orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = Color(0xFFFAFAFA),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = onToggleEdit) {
|
||||
Text(if (editEnabled) "Startseiten-Editor schließen" else "Startseite anpassen")
|
||||
}
|
||||
if (editEnabled) {
|
||||
Text(
|
||||
"Elemente ein-/ausblenden und Reihenfolge festlegen.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Accent700,
|
||||
)
|
||||
sections.forEachIndexed { index, section ->
|
||||
val label = homeSectionLabels[section.id] ?: section.id
|
||||
val sectionKey = homeSectionKey(section)
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
|
||||
Text(section.id, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Anzeigen", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = section.enabled,
|
||||
onCheckedChange = { enabled -> onEnabledChange(sectionKey, enabled) },
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedButton(onClick = { onMoveUp(sectionKey) }, enabled = index > 0) { Text("Hoch") }
|
||||
OutlinedButton(onClick = { onMoveDown(sectionKey) }, enabled = index < sections.lastIndex) { Text("Runter") }
|
||||
}
|
||||
}
|
||||
|
||||
if (section.id == "spielplan_team") {
|
||||
SpielplanWidgetConfigEditor(
|
||||
section = section,
|
||||
seasons = spielplanSeasons,
|
||||
teamsBySeason = spielplanTeamsBySeason,
|
||||
onUpdate = { season, teamName, teamAgeGroup ->
|
||||
onUpdateSpielplanWidget(sectionKey, season, teamName, teamAgeGroup)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Widget hinzufügen", style = MaterialTheme.typography.titleMedium, color = Accent900)
|
||||
Text("Spielplan Mannschaft", style = MaterialTheme.typography.bodyMedium, color = Accent700)
|
||||
|
||||
SimpleSelector(
|
||||
label = "Saison",
|
||||
selected = spielplanSeasons.firstOrNull { it.slug == addSeason }?.let { formatSeasonLabel(it.slug) }
|
||||
?: "Bitte wählen",
|
||||
options = spielplanSeasons.map { season ->
|
||||
SelectOption(
|
||||
key = season.slug,
|
||||
label = formatSeasonLabel(season.slug),
|
||||
)
|
||||
},
|
||||
onSelect = { option ->
|
||||
addSeason = option.key
|
||||
},
|
||||
)
|
||||
|
||||
SimpleSelector(
|
||||
label = "Mannschaft",
|
||||
selected = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey }?.label ?: "Bitte wählen",
|
||||
options = addTeamOptions.map { team ->
|
||||
SelectOption(
|
||||
key = teamOptionKey(team),
|
||||
label = team.label,
|
||||
)
|
||||
},
|
||||
onSelect = { option ->
|
||||
addTeamKey = option.key
|
||||
},
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val selectedTeam = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey } ?: return@Button
|
||||
onAddSpielplanWidget(addSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
|
||||
},
|
||||
enabled = addSeason.isNotBlank() && addTeamKey.isNotBlank(),
|
||||
) {
|
||||
Text("Widget hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onReset) {
|
||||
Text("Auf Server-Standard zurücksetzen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SelectOption(
|
||||
val key: String,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SimpleSelector(
|
||||
label: String,
|
||||
selected: String,
|
||||
options: List<SelectOption>,
|
||||
onSelect: (SelectOption) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
OutlinedButton(onClick = { expanded = true }, enabled = options.isNotEmpty()) {
|
||||
Text(selected)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.label) },
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpielplanWidgetConfigEditor(
|
||||
section: HomepageSectionDto,
|
||||
seasons: List<SeasonDto>,
|
||||
teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||
onUpdate: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||
) {
|
||||
val selectedSeason = section.config?.season.orEmpty()
|
||||
val selectedTeamName = section.config?.teamName.orEmpty()
|
||||
val selectedTeamAgeGroup = section.config?.teamAgeGroup.orEmpty()
|
||||
val teamOptions = teamsBySeason[selectedSeason].orEmpty()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SimpleSelector(
|
||||
label = "Saison",
|
||||
selected = seasons.firstOrNull { it.slug == selectedSeason }?.let { formatSeasonLabel(it.slug) } ?: "Bitte wählen",
|
||||
options = seasons.map { season -> SelectOption(season.slug, formatSeasonLabel(season.slug)) },
|
||||
onSelect = { option ->
|
||||
val fallbackTeam = teamsBySeason[option.key].orEmpty().firstOrNull()
|
||||
onUpdate(
|
||||
option.key,
|
||||
fallbackTeam?.teamName ?: "",
|
||||
fallbackTeam?.teamAgeGroup ?: "",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SimpleSelector(
|
||||
label = "Mannschaft",
|
||||
selected = teamOptions.firstOrNull {
|
||||
it.teamName == selectedTeamName && it.teamAgeGroup == selectedTeamAgeGroup
|
||||
}?.label ?: "Bitte wählen",
|
||||
options = teamOptions.map { team -> SelectOption(teamOptionKey(team), team.label) },
|
||||
onSelect = { option ->
|
||||
val selectedTeam = teamOptions.firstOrNull { teamOptionKey(it) == option.key }
|
||||
if (selectedTeam != null) {
|
||||
onUpdate(selectedSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSpielplanTeamWidgetSection(
|
||||
section: HomepageSectionDto,
|
||||
spiele: List<SpielDto>,
|
||||
error: String?,
|
||||
loading: Boolean,
|
||||
onOpenAll: () -> Unit,
|
||||
) {
|
||||
val teamName = section.config?.teamName.orEmpty()
|
||||
val teamAgeGroup = section.config?.teamAgeGroup.orEmpty()
|
||||
val title = if (teamAgeGroup.contains("jugend", ignoreCase = true) && teamName.isNotBlank()) {
|
||||
"Spielplan: (J) $teamName"
|
||||
} else {
|
||||
"Spielplan: ${teamName.ifBlank { "Mannschaft" }}"
|
||||
}
|
||||
val season = section.config?.season.orEmpty()
|
||||
|
||||
HomeSection(title = title, subtitle = "Saison ${formatSeasonLabel(season)}", background = Color.White) {
|
||||
if (loading) {
|
||||
LoadingRow("Spiele werden geladen...")
|
||||
} else if (!error.isNullOrBlank()) {
|
||||
EmptyRow(error)
|
||||
} else if (spiele.isEmpty()) {
|
||||
EmptyRow("Keine kommenden Spiele für diese Mannschaft gefunden.")
|
||||
} else {
|
||||
spiele.forEach { spiel -> MatchCard(spiel) }
|
||||
}
|
||||
PrimaryAction("Voller Spielplan", onOpenAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebHero(imageUrl: String?) {
|
||||
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 390.dp)
|
||||
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize().alpha(0.10f),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text(
|
||||
"Willkommen beim",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Harheimer TC",
|
||||
style = MaterialTheme.typography.displayLarge.copy(fontSize = 40.sp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Primary600,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit $years Jahren",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Accent700,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeTermineSection(termine: List<TerminDto>, loading: Boolean, onAll: () -> Unit) {
|
||||
HomeSection(title = "Kommende Termine", background = Color(0xFFFAFAFA)) {
|
||||
if (loading) {
|
||||
LoadingRow("Termine werden geladen...")
|
||||
} else if (termine.isEmpty()) {
|
||||
EmptyRow("Keine kommenden Termine")
|
||||
} else {
|
||||
termine.forEach { termin -> AppointmentCard(termin) }
|
||||
}
|
||||
PrimaryAction("Alle Termine anzeigen", onAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeGamesSection(spiele: List<SpielDto>, loading: Boolean, onAll: () -> Unit) {
|
||||
HomeSection(title = "Nächste Spiele", background = Color.White) {
|
||||
if (loading) {
|
||||
LoadingRow("Spielplan wird geladen...")
|
||||
} else if (spiele.isEmpty()) {
|
||||
EmptyRow("Derzeit sind keine Spiele geplant.")
|
||||
} else {
|
||||
spiele.forEach { spiel -> MatchCard(spiel) }
|
||||
}
|
||||
PrimaryAction("Alle Spiele anzeigen", onAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
|
||||
HomeSection(
|
||||
title = "Aktuelles",
|
||||
subtitle = "Die neuesten Nachrichten aus unserem Verein",
|
||||
background = Color.White,
|
||||
) {
|
||||
news.forEach { item ->
|
||||
Surface(
|
||||
color = Color(0xFFFAFAFA),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp).clickable { onOpen(item) },
|
||||
) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
RichText(item.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
|
||||
HomeSection(title = null, background = Color(0xFFFAFAFA)) {
|
||||
ActionCard(
|
||||
title = "Mitglied werden",
|
||||
body = "Werden Sie Teil unserer Tischtennisfamilie und profitieren Sie von Training, Wettkämpfen und Gemeinschaft.",
|
||||
action = "Mehr erfahren",
|
||||
onClick = onMembership,
|
||||
)
|
||||
ActionCard(
|
||||
title = "Kontakt aufnehmen",
|
||||
body = "Haben Sie Fragen oder möchten ein kostenloses Probetraining vereinbaren? Wir freuen uns auf Ihre Nachricht!",
|
||||
action = "Jetzt kontaktieren",
|
||||
onClick = onContact,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeExtraActionSection(title: String, body: String, action: String, onClick: () -> Unit) {
|
||||
HomeSection(title = null, background = Color.White) {
|
||||
ActionCard(
|
||||
title = title,
|
||||
body = body,
|
||||
action = action,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSection(
|
||||
title: String?,
|
||||
subtitle: String? = null,
|
||||
background: Color,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().background(background).padding(horizontal = 18.dp, vertical = 38.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
title?.let {
|
||||
Text(it, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.width(74.dp).height(4.dp).background(Primary600))
|
||||
subtitle?.let { text ->
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, color = Accent500, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(26.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppointmentCard(termin: TerminDto) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
color = Color(0xFFF4F4F5),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.width(65.dp).padding(vertical = 8.dp, horizontal = 3.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(formatDate(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 19.sp)
|
||||
Text(formatDate(termin.datum, "MMM yyyy"), color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
termin.uhrzeit?.let { Text("$it Uhr", color = Color.White, style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(termin.titel, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
termin.beschreibung?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent700, maxLines = 2)
|
||||
}
|
||||
}
|
||||
termin.kategorie?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Accent700,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.background(Primary100, RoundedCornerShape(14.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchCard(spiel: SpielDto) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMatchDate(spiel.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text(spiel.termin.substringAfter(' ', "-"), style = MaterialTheme.typography.bodyMedium, color = Accent500)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TeamLabel("Heim", spiel.heimMannschaft, Modifier.weight(1f))
|
||||
Box(
|
||||
Modifier.size(34.dp).background(Primary100, RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("vs", color = Primary600, fontWeight = FontWeight.Bold) }
|
||||
TeamLabel("Gast", spiel.gastMannschaft, Modifier.weight(1f), right = true)
|
||||
}
|
||||
spiel.runde?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamLabel(label: String, value: String, modifier: Modifier, right: Boolean = false) {
|
||||
Column(modifier.padding(horizontal = 8.dp), horizontalAlignment = if (right) Alignment.End else Alignment.Start) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(value, fontWeight = FontWeight.SemiBold, color = Accent900, textAlign = if (right) TextAlign.End else TextAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionCard(title: String, body: String, action: String, onClick: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 15.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(48.dp).background(Primary100, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("HTC", color = Primary600, fontWeight = FontWeight.Bold, fontSize = 11.sp) }
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
}
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = Accent700)
|
||||
Text("$action >", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryAction(label: String, onClick: () -> Unit) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) { Text("$label >", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyRow(text: String) {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(9.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 30.dp, horizontal = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow(text: String) {
|
||||
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp), color = Primary600)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(text, color = Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeFooter() {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(Accent900).padding(horizontal = 18.dp, vertical = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Harheimer TC", style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text("Tischtennis in Frankfurt-Harheim seit 1954", color = Accent200, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(value: String, pattern: String): String = runCatching {
|
||||
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
|
||||
SimpleDateFormat(pattern, Locale.GERMANY).format(source.parse(value)!!)
|
||||
}.getOrDefault(value)
|
||||
|
||||
private fun formatMatchDate(value: String): String = runCatching {
|
||||
val source = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
|
||||
val date = source.parse(value.substringBefore(' '))!!
|
||||
SimpleDateFormat("EEE dd.MM.yyyy", Locale.GERMANY).format(date)
|
||||
}.getOrDefault(value.substringBefore(' '))
|
||||
|
||||
private fun formatNewsDate(value: String?): String {
|
||||
if (value.isNullOrBlank()) return ""
|
||||
return runCatching {
|
||||
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
|
||||
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
|
||||
}.getOrDefault(value.take(10))
|
||||
}
|
||||
|
||||
private fun homeSectionKey(section: HomepageSectionDto): String =
|
||||
section.key?.takeIf { it.isNotBlank() } ?: section.id
|
||||
|
||||
private fun teamOptionKey(option: HomeSpielplanTeamOption): String =
|
||||
"${option.teamName}|${option.teamAgeGroup}"
|
||||
|
||||
private fun formatSeasonLabel(value: String): String {
|
||||
val match = Regex("^(\\d{2})--(\\d{2})$").find(value)
|
||||
if (match == null) return value.ifBlank { "-" }
|
||||
return "20${match.groupValues[1]}/${match.groupValues[2]}"
|
||||
}
|
||||
|
||||
private val homeSectionLabels = mapOf(
|
||||
"banner" to "Banner (Willkommen)",
|
||||
"aktuelles" to "Aktuelles",
|
||||
"termine" to "Kommende Termine",
|
||||
"spiele" to "Nächste Spiele",
|
||||
"kontakt" to "Kontakt-Boxen",
|
||||
"training" to "Training-Teaser",
|
||||
"links" to "Links-Teaser",
|
||||
"vereinsmeisterschaften" to "Vereinsmeisterschaften-Teaser",
|
||||
"spielplan_team" to "Widget: Spielplan Mannschaft",
|
||||
)
|
||||
421
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt
Executable file
421
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt
Executable file
@@ -0,0 +1,421 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.HomepageSectionConfigDto
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.HomeLayoutPreferences
|
||||
import de.harheimertc.repositories.HomeRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeSpielplanTeamOption(
|
||||
val teamName: String,
|
||||
val teamAgeGroup: String,
|
||||
) {
|
||||
val label: String
|
||||
get() = if (teamAgeGroup.contains("jugend", ignoreCase = true)) {
|
||||
"(J) $teamName"
|
||||
} else {
|
||||
teamName
|
||||
}
|
||||
}
|
||||
|
||||
data class HomeUiState(
|
||||
val loading: Boolean = true,
|
||||
val heroImageUrl: String? = null,
|
||||
val termine: List<TerminDto> = emptyList(),
|
||||
val spiele: List<SpielDto> = emptyList(),
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val homepageSections: List<HomepageSectionDto> = defaultHomepageSections,
|
||||
val spielplanSeasons: List<SeasonDto> = emptyList(),
|
||||
val spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>> = emptyMap(),
|
||||
val spielplanWidgetPreviews: Map<String, List<SpielDto>> = emptyMap(),
|
||||
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
|
||||
val widgetsLoading: Boolean = false,
|
||||
val error: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val debugDiagnostics: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val repository: HomeRepository,
|
||||
private val layoutPreferences: HomeLayoutPreferences,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomeUiState())
|
||||
val state: StateFlow<HomeUiState> = _state
|
||||
private var serverSections: List<HomepageSectionDto> = defaultHomepageSections
|
||||
private val seasonGamesCache = mutableMapOf<String, List<SpielDto>>()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
error = false,
|
||||
errorMessage = null,
|
||||
debugDiagnostics = emptyList(),
|
||||
)
|
||||
repository.fetchHomeData()
|
||||
.onSuccess { data ->
|
||||
serverSections = normalizedHomepageSections(data.homepageSections)
|
||||
val sections = mergeWithUserSections(
|
||||
server = serverSections,
|
||||
user = layoutPreferences.getSections(),
|
||||
)
|
||||
seasonGamesCache.clear()
|
||||
data.selectedSpielplanSeason?.takeIf { it.isNotBlank() }?.let { season ->
|
||||
seasonGamesCache[season] = data.spiele
|
||||
}
|
||||
val widgetData = loadWidgetData(
|
||||
sections = sections,
|
||||
seasons = data.spielplanSeasons,
|
||||
)
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
heroImageUrl = data.heroImageUrl,
|
||||
termine = data.termine
|
||||
.filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
|
||||
.sortedBy { it.asDateTime() }
|
||||
.take(3),
|
||||
spiele = data.spiele
|
||||
.filter { game ->
|
||||
game.asDate()?.let { date ->
|
||||
!date.isBefore(LocalDate.now()) &&
|
||||
!date.isAfter(LocalDate.now().plusDays(7))
|
||||
} == true
|
||||
}
|
||||
.sortedBy { it.asDate() }
|
||||
.take(3),
|
||||
news = data.news.take(3),
|
||||
homepageSections = sections,
|
||||
spielplanSeasons = widgetData.seasons,
|
||||
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||
debugDiagnostics = data.diagnostics,
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
error = true,
|
||||
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
|
||||
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveSectionUp(sectionKey: String) {
|
||||
updateSections { sections ->
|
||||
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
|
||||
if (index <= 0) return@updateSections sections
|
||||
sections.toMutableList().also { list ->
|
||||
val current = list.removeAt(index)
|
||||
list.add(index - 1, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveSectionDown(sectionKey: String) {
|
||||
updateSections { sections ->
|
||||
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
|
||||
if (index < 0 || index >= sections.lastIndex) return@updateSections sections
|
||||
sections.toMutableList().also { list ->
|
||||
val current = list.removeAt(index)
|
||||
list.add(index + 1, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSectionEnabled(sectionKey: String, enabled: Boolean) {
|
||||
updateSections { sections ->
|
||||
sections.map { section ->
|
||||
if (sectionKey(section) == sectionKey) section.copy(enabled = enabled) else section
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addSpielplanTeamWidget(season: String, teamName: String, teamAgeGroup: String) {
|
||||
val normalizedSeason = season.trim()
|
||||
val normalizedTeamName = teamName.trim()
|
||||
if (normalizedSeason.isBlank() || normalizedTeamName.isBlank()) return
|
||||
val newSection = HomepageSectionDto(
|
||||
id = WIDGET_SECTION_ID,
|
||||
key = "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
|
||||
enabled = true,
|
||||
config = HomepageSectionConfigDto(
|
||||
season = normalizedSeason,
|
||||
teamName = normalizedTeamName,
|
||||
teamAgeGroup = teamAgeGroup.trim(),
|
||||
),
|
||||
)
|
||||
updateSections { sections -> sections + newSection }
|
||||
refreshWidgetData()
|
||||
}
|
||||
|
||||
fun updateSpielplanTeamWidget(
|
||||
sectionKey: String,
|
||||
season: String,
|
||||
teamName: String,
|
||||
teamAgeGroup: String,
|
||||
) {
|
||||
updateSections { sections ->
|
||||
sections.map { section ->
|
||||
if (sectionKey(section) != sectionKey) return@map section
|
||||
section.copy(
|
||||
config = HomepageSectionConfigDto(
|
||||
season = season.trim(),
|
||||
teamName = teamName.trim(),
|
||||
teamAgeGroup = teamAgeGroup.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
refreshWidgetData()
|
||||
}
|
||||
|
||||
fun resetSections() {
|
||||
val reset = serverSections
|
||||
layoutPreferences.clearSections()
|
||||
_state.value = _state.value.copy(homepageSections = reset)
|
||||
refreshWidgetData()
|
||||
}
|
||||
|
||||
private fun updateSections(transform: (List<HomepageSectionDto>) -> List<HomepageSectionDto>) {
|
||||
val updated = transform(_state.value.homepageSections)
|
||||
if (updated == _state.value.homepageSections) return
|
||||
layoutPreferences.setSections(updated)
|
||||
_state.value = _state.value.copy(homepageSections = updated)
|
||||
}
|
||||
|
||||
private fun refreshWidgetData() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(widgetsLoading = true)
|
||||
val widgetData = loadWidgetData(
|
||||
sections = _state.value.homepageSections,
|
||||
seasons = _state.value.spielplanSeasons,
|
||||
)
|
||||
_state.value = _state.value.copy(
|
||||
spielplanSeasons = widgetData.seasons,
|
||||
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||
widgetsLoading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadWidgetData(
|
||||
sections: List<HomepageSectionDto>,
|
||||
seasons: List<SeasonDto>,
|
||||
): HomeWidgetData {
|
||||
val allSeasons = seasons
|
||||
.filter { it.slug.isNotBlank() }
|
||||
.distinctBy { it.slug }
|
||||
.toMutableList()
|
||||
seasonGamesCache.keys.forEach { slug ->
|
||||
if (allSeasons.none { it.slug == slug }) {
|
||||
allSeasons += SeasonDto(slug = slug, label = slug)
|
||||
}
|
||||
}
|
||||
val neededWidgetSeasons = sections
|
||||
.asSequence()
|
||||
.filter { it.id == WIDGET_SECTION_ID }
|
||||
.mapNotNull { it.config?.season?.takeIf(String::isNotBlank) }
|
||||
.toSet()
|
||||
|
||||
allSeasons.forEach { season ->
|
||||
ensureSeasonLoaded(season.slug)
|
||||
}
|
||||
neededWidgetSeasons.forEach { season ->
|
||||
if (allSeasons.none { it.slug == season }) {
|
||||
allSeasons += SeasonDto(slug = season, label = season)
|
||||
}
|
||||
ensureSeasonLoaded(season)
|
||||
}
|
||||
|
||||
val teamsBySeason = buildMap {
|
||||
allSeasons.forEach { season ->
|
||||
val games = seasonGamesCache[season.slug] ?: return@forEach
|
||||
put(season.slug, extractHarheimerTeams(games))
|
||||
}
|
||||
}
|
||||
|
||||
val previews = mutableMapOf<String, List<SpielDto>>()
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
sections.forEach { section ->
|
||||
if (section.id != WIDGET_SECTION_ID) return@forEach
|
||||
val key = sectionKey(section)
|
||||
val config = section.config
|
||||
val season = config?.season.orEmpty()
|
||||
val teamName = config?.teamName.orEmpty()
|
||||
if (season.isBlank() || teamName.isBlank()) {
|
||||
errors[key] = "Bitte Saison und Mannschaft wählen."
|
||||
previews[key] = emptyList()
|
||||
return@forEach
|
||||
}
|
||||
val games = seasonGamesCache[season]
|
||||
if (games == null) {
|
||||
errors[key] = "Spielplan konnte nicht geladen werden."
|
||||
previews[key] = emptyList()
|
||||
return@forEach
|
||||
}
|
||||
previews[key] = filterUpcomingTeamGames(games, teamName, config?.teamAgeGroup.orEmpty())
|
||||
}
|
||||
|
||||
return HomeWidgetData(
|
||||
seasons = allSeasons,
|
||||
teamsBySeason = teamsBySeason,
|
||||
previewGamesBySectionKey = previews,
|
||||
errorsBySectionKey = errors,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ensureSeasonLoaded(season: String) {
|
||||
if (seasonGamesCache.containsKey(season)) return
|
||||
repository.fetchSpielplanForSeason(season).onSuccess { response ->
|
||||
seasonGamesCache[season] = response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class HomeWidgetData(
|
||||
val seasons: List<SeasonDto>,
|
||||
val teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||
val previewGamesBySectionKey: Map<String, List<SpielDto>>,
|
||||
val errorsBySectionKey: Map<String, String>,
|
||||
)
|
||||
|
||||
private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
|
||||
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
|
||||
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
}.getOrNull()
|
||||
|
||||
fun SpielDto.asDate(): LocalDate? = runCatching {
|
||||
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
|
||||
}.getOrNull()
|
||||
|
||||
private val defaultHomepageSections = listOf(
|
||||
HomepageSectionDto(id = "banner", key = "banner", enabled = true),
|
||||
HomepageSectionDto(id = "aktuelles", key = "aktuelles", enabled = true),
|
||||
HomepageSectionDto(id = "termine", key = "termine", enabled = true),
|
||||
HomepageSectionDto(id = "spiele", key = "spiele", enabled = true),
|
||||
HomepageSectionDto(id = "kontakt", key = "kontakt", enabled = true),
|
||||
HomepageSectionDto(id = "training", key = "training", enabled = false),
|
||||
HomepageSectionDto(id = "links", key = "links", enabled = false),
|
||||
HomepageSectionDto(id = "vereinsmeisterschaften", key = "vereinsmeisterschaften", enabled = false),
|
||||
)
|
||||
|
||||
private fun normalizedHomepageSections(configuredSections: List<HomepageSectionDto>): List<HomepageSectionDto> {
|
||||
val configured = configuredSections
|
||||
.filter { it.id.isNotBlank() }
|
||||
.mapIndexed { index, section ->
|
||||
val fallback = if (section.id == WIDGET_SECTION_ID) "${section.id}_${index + 1}" else section.id
|
||||
section.copy(key = section.key?.takeIf { it.isNotBlank() } ?: fallback)
|
||||
}
|
||||
val knownIds = configured.map { it.id }.toMutableSet()
|
||||
return buildList {
|
||||
addAll(configured)
|
||||
defaultHomepageSections.forEach { section ->
|
||||
if (knownIds.add(section.id)) add(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeWithUserSections(
|
||||
server: List<HomepageSectionDto>,
|
||||
user: List<HomepageSectionDto>?,
|
||||
): List<HomepageSectionDto> {
|
||||
if (user.isNullOrEmpty()) return server
|
||||
val serverById = server.associateBy { it.id }
|
||||
val serverByKey = server.associateBy { sectionKey(it) }
|
||||
val ordered = buildList<HomepageSectionDto> {
|
||||
user.forEach { userSection ->
|
||||
val matchedServerSection = serverByKey[sectionKey(userSection)]
|
||||
?: if (userSection.id == WIDGET_SECTION_ID) null else serverById[userSection.id]
|
||||
if (matchedServerSection != null) {
|
||||
if (none { sectionKey(it) == sectionKey(matchedServerSection) }) {
|
||||
add(
|
||||
matchedServerSection.copy(
|
||||
enabled = userSection.enabled,
|
||||
key = sectionKey(userSection),
|
||||
config = userSection.config,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
if (userSection.id == WIDGET_SECTION_ID && none { sectionKey(it) == sectionKey(userSection) }) {
|
||||
add(
|
||||
userSection.copy(
|
||||
key = userSection.key?.takeIf { it.isNotBlank() }
|
||||
?: "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
server.forEach { serverSection ->
|
||||
if (none { sectionKey(it) == sectionKey(serverSection) }) add(serverSection)
|
||||
}
|
||||
}
|
||||
return ordered.ifEmpty { server }
|
||||
}
|
||||
|
||||
private fun sectionKey(section: HomepageSectionDto): String =
|
||||
section.key?.takeIf { it.isNotBlank() } ?: section.id
|
||||
|
||||
private fun extractHarheimerTeams(games: List<SpielDto>): List<HomeSpielplanTeamOption> =
|
||||
games
|
||||
.flatMap { game ->
|
||||
listOf(
|
||||
HomeSpielplanTeamOption(game.heimMannschaft.trim(), game.heimAltersklasse.trim()),
|
||||
HomeSpielplanTeamOption(game.gastMannschaft.trim(), game.gastAltersklasse.trim()),
|
||||
)
|
||||
}
|
||||
.filter { option -> option.teamName.contains("Harheimer TC", ignoreCase = true) }
|
||||
.filter { option -> option.teamName.isNotBlank() }
|
||||
.distinctBy { "${it.teamName}|${it.teamAgeGroup}" }
|
||||
.sortedBy { it.label }
|
||||
|
||||
private fun filterUpcomingTeamGames(
|
||||
games: List<SpielDto>,
|
||||
teamName: String,
|
||||
teamAgeGroup: String,
|
||||
): List<SpielDto> {
|
||||
val normalizedTeam = teamName.trim()
|
||||
val normalizedAgeGroup = teamAgeGroup.trim()
|
||||
val today = LocalDate.now()
|
||||
return games
|
||||
.asSequence()
|
||||
.filter { game ->
|
||||
val homeMatch = game.heimMannschaft.trim() == normalizedTeam &&
|
||||
(normalizedAgeGroup.isBlank() || game.heimAltersklasse.trim() == normalizedAgeGroup)
|
||||
val awayMatch = game.gastMannschaft.trim() == normalizedTeam &&
|
||||
(normalizedAgeGroup.isBlank() || game.gastAltersklasse.trim() == normalizedAgeGroup)
|
||||
homeMatch || awayMatch
|
||||
}
|
||||
.filter { game -> game.asDate()?.let { !it.isBefore(today) } == true }
|
||||
.sortedBy { it.asDate() }
|
||||
.take(5)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private const val WIDGET_SECTION_ID = "spielplan_team"
|
||||
153
android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt
Executable file
153
android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt
Executable file
@@ -0,0 +1,153 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.loggedIn, state.restoring) {
|
||||
if (state.loggedIn && !state.restoring) {
|
||||
navController.navigate(Destinations.MemberArea.route) {
|
||||
launchSingleTop = true
|
||||
popUpTo(Destinations.Login.route) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
||||
Text(
|
||||
"Mitglieder-Login",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"Melden Sie sich an, um auf den Mitgliederbereich zuzugreifen.",
|
||||
color = Accent500,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 9.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (state.restoring) {
|
||||
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
||||
Text("Sitzung wird geprüft...", color = Accent500)
|
||||
} else if (!state.loggedIn) {
|
||||
ValidatedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::setEmail,
|
||||
label = "E-Mail-Adresse",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
)
|
||||
ValidatedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::setPassword,
|
||||
label = "Passwort",
|
||||
error = state.fieldErrors["password"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
|
||||
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.passkeyLogin(context) },
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Mit Passkey anmelden")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Passwort vergessen?")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.Register.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Registrierung beantragen")
|
||||
}
|
||||
} else {
|
||||
Text("Angemeldet", style = MaterialTheme.typography.titleLarge, color = Color(0xFF166534))
|
||||
Text(state.userName.orEmpty(), color = Accent900)
|
||||
if (state.roles.isNotEmpty()) Text(state.roles.joinToString(", "), color = Accent500)
|
||||
OutlinedButton(onClick = viewModel::logout, modifier = Modifier.fillMaxWidth()) { Text("Abmelden") }
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text(
|
||||
"Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.",
|
||||
color = Primary900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user