Compare commits
1 Commits
dev
...
ecae88af78
| Author | SHA1 | Date | |
|---|---|---|---|
| ecae88af78 |
25
.gitea/workflows/code-analysis.yml
Executable file → Normal file
25
.gitea/workflows/code-analysis.yml
Executable file → Normal file
@@ -10,19 +10,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
clean: true
|
clean: true
|
||||||
# Der Analyse-Workflow benötigt keine Historie. Ein flacher Checkout
|
fetch-depth: 0
|
||||||
# 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
|
- name: Ensure clean workspace
|
||||||
run: |
|
run: |
|
||||||
@@ -38,7 +29,7 @@ jobs:
|
|||||||
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -116,18 +107,16 @@ jobs:
|
|||||||
- name: Semgrep (SAST)
|
- name: Semgrep (SAST)
|
||||||
run: semgrep --config p/default --error .
|
run: semgrep --config p/default --error .
|
||||||
|
|
||||||
- name: npm audit (critical)
|
- name: npm audit (high+)
|
||||||
run: npm audit --audit-level=critical
|
run: npm audit --audit-level=high
|
||||||
|
|
||||||
- name: OSV-Scanner (SCA)
|
- name: OSV-Scanner (SCA)
|
||||||
run: |
|
run: |
|
||||||
cd "$GITHUB_WORKSPACE"
|
|
||||||
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
||||||
chmod +x osv-scanner
|
chmod +x osv-scanner
|
||||||
./osv-scanner --version
|
./osv-scanner --version
|
||||||
test -f "$GITHUB_WORKSPACE/package-lock.json"
|
test -f ./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
|
./osv-scanner --lockfile ./package-lock.json
|
||||||
node scripts/check-osv-severity.mjs osv-report.json
|
|
||||||
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
0
.gitea/workflows/version-gate.yml
Executable file → Normal file
0
.gitea/workflows/version-gate.yml
Executable file → Normal file
6
.github/workflows/android-ci.yml
vendored
Executable file → Normal file
6
.github/workflows/android-ci.yml
vendored
Executable file → Normal file
@@ -10,14 +10,14 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@v4
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
|
|||||||
4
.gitignore
vendored
Executable file → Normal file
4
.gitignore
vendored
Executable file → Normal file
@@ -94,10 +94,6 @@ dist
|
|||||||
/android-app/**/build/
|
/android-app/**/build/
|
||||||
/android-app/local.properties
|
/android-app/local.properties
|
||||||
/android-app/gradle-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!)
|
# Build output (but keep production data!)
|
||||||
.output
|
.output
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
[[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$''',
|
|
||||||
]
|
|
||||||
0
.gitleaksignore
Executable file → Normal file
0
.gitleaksignore
Executable file → Normal file
@@ -1,2 +0,0 @@
|
|||||||
# Retained because the CI scanner is invoked with --config. Findings are
|
|
||||||
# evaluated transparently by scripts/check-osv-severity.mjs.
|
|
||||||
0
.semgrepignore
Executable file → Normal file
0
.semgrepignore
Executable file → Normal file
0
ANDROID_ARCHITECTURE.md
Executable file → Normal file
0
ANDROID_ARCHITECTURE.md
Executable file → Normal file
0
ANDROID_KOTLIN_PLAN.md
Executable file → Normal file
0
ANDROID_KOTLIN_PLAN.md
Executable file → Normal file
0
ANDROID_PORT_TODO.md
Executable file → Normal file
0
ANDROID_PORT_TODO.md
Executable file → Normal file
0
ANDROID_REPO_ENDPOINTS.md
Executable file → Normal file
0
ANDROID_REPO_ENDPOINTS.md
Executable file → Normal file
0
AUTH_README.md
Executable file → Normal file
0
AUTH_README.md
Executable file → Normal file
0
DATENSCHUTZ.md
Executable file → Normal file
0
DATENSCHUTZ.md
Executable file → Normal file
0
DATENSCHUTZ_UEBERSICHT.md
Executable file → Normal file
0
DATENSCHUTZ_UEBERSICHT.md
Executable file → Normal file
0
DEPLOYMENT.md
Executable file → Normal file
0
DEPLOYMENT.md
Executable file → Normal file
@@ -1,32 +0,0 @@
|
|||||||
# 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
|
|
||||||
0
android-app/PLAYSTORE_ASSETS.md
Executable file → Normal file
0
android-app/PLAYSTORE_ASSETS.md
Executable file → Normal file
11
android-app/app/build.gradle.kts
Executable file → Normal file
11
android-app/app/build.gradle.kts
Executable file → Normal file
@@ -78,23 +78,16 @@ val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.harheimertc"
|
namespace = "de.harheimertc"
|
||||||
compileSdk = 36
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.harheimertc"
|
applicationId = "de.harheimertc"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 35
|
||||||
versionCode = androidVersionCode
|
versionCode = androidVersionCode
|
||||||
versionName = androidVersionName
|
versionName = androidVersionName
|
||||||
}
|
}
|
||||||
|
|
||||||
lint {
|
|
||||||
disable += setOf(
|
|
||||||
"AutoboxingStateCreation",
|
|
||||||
"MutableCollectionMutableState",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
if (hasReleaseSigning) {
|
if (hasReleaseSigning) {
|
||||||
|
|||||||
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file
Binary file not shown.
BIN
android-app/app/local/release/app-local-release.aab
Normal file
BIN
android-app/app/local/release/app-local-release.aab
Normal file
Binary file not shown.
BIN
android-app/app/production/release/app-production-release.aab
Normal file
BIN
android-app/app/production/release/app-production-release.aab
Normal file
Binary file not shown.
0
android-app/app/proguard-rules.pro
vendored
Executable file → Normal file
0
android-app/app/proguard-rules.pro
vendored
Executable file → Normal file
0
android-app/app/src/androidTest/AndroidManifest.xml
Executable file → Normal file
0
android-app/app/src/androidTest/AndroidManifest.xml
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestBindingsModule.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestBindingsModule.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/TestActivity.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsActivateResendTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsActivateResendTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsRolesDialogTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsRolesDialogTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/gallery/GalleryScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/gallery/GalleryScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/home/HomeScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/home/HomeScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/login/LoginScreenTest.kt
Executable file → Normal file
0
android-app/app/src/androidTest/java/de/harheimertc/ui/screens/login/LoginScreenTest.kt
Executable file → Normal file
0
android-app/app/src/debug/AndroidManifest.xml
Executable file → Normal file
0
android-app/app/src/debug/AndroidManifest.xml
Executable file → Normal file
0
android-app/app/src/main/AndroidManifest.xml
Executable file → Normal file
0
android-app/app/src/main/AndroidManifest.xml
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt
Executable file → Normal file
28
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Executable file → Normal file
28
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Executable file → Normal file
@@ -21,20 +21,9 @@ import de.harheimertc.ui.navigation.NavigationViewModel
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@Inject
|
|
||||||
lateinit var authRepository: AuthRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var pushTokenRepository: PushTokenRepository
|
|
||||||
|
|
||||||
private val notificationRoute = mutableStateOf<String?>(null)
|
private val notificationRoute = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
private val notificationPermissionLauncher = registerForActivityResult(
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
@@ -61,23 +50,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
notificationRoute.value = extractNotificationRoute(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? =
|
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||||
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/AccessTokenAuthenticator.kt
Executable file → Normal file
54
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Executable file → Normal file
54
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Executable file → Normal file
@@ -21,7 +21,6 @@ import okhttp3.RequestBody
|
|||||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
|
||||||
data class TerminDto(
|
data class TerminDto(
|
||||||
val datum: String = "",
|
val datum: String = "",
|
||||||
val uhrzeit: String? = null,
|
val uhrzeit: String? = null,
|
||||||
@@ -37,22 +36,6 @@ data class SpielplanResponse(
|
|||||||
val season: String? = null,
|
val season: String? = null,
|
||||||
val seasons: List<SeasonDto> = emptyList(),
|
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 SeasonDto(val slug: String = "", val label: String = "")
|
||||||
data class MannschaftenSeasonsResponse(
|
data class MannschaftenSeasonsResponse(
|
||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
@@ -182,7 +165,6 @@ data class MembershipResponse(
|
|||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val downloadUrl: String? = null,
|
val downloadUrl: String? = null,
|
||||||
val downloadToken: String? = null,
|
|
||||||
)
|
)
|
||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
val email: String,
|
val email: String,
|
||||||
@@ -249,7 +231,7 @@ data class ProfileVisibilityDto(
|
|||||||
val showEmail: Boolean = true,
|
val showEmail: Boolean = true,
|
||||||
val showPhone: Boolean = true,
|
val showPhone: Boolean = true,
|
||||||
val showAddress: Boolean = false,
|
val showAddress: Boolean = false,
|
||||||
val showBirthday: Boolean = false,
|
val showBirthday: Boolean = true,
|
||||||
)
|
)
|
||||||
data class ProfileUserDto(
|
data class ProfileUserDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
@@ -285,10 +267,6 @@ data class NotificationSettingsDto(
|
|||||||
val birthdays: Boolean = false,
|
val birthdays: Boolean = false,
|
||||||
val newContactRequest: Boolean = false,
|
val newContactRequest: Boolean = false,
|
||||||
val newUserRegistration: 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 selectedTeamSlugs: List<String> = emptyList(),
|
||||||
val selectedTeamSeason: String? = null,
|
val selectedTeamSeason: String? = null,
|
||||||
val notificationTime: String = "09:00",
|
val notificationTime: String = "09:00",
|
||||||
@@ -302,7 +280,6 @@ data class PushTokenRequest(
|
|||||||
val token: String,
|
val token: String,
|
||||||
val platform: String = "android",
|
val platform: String = "android",
|
||||||
val appVersion: String? = null,
|
val appVersion: String? = null,
|
||||||
val installationId: String? = null,
|
|
||||||
)
|
)
|
||||||
data class BirthdayDto(
|
data class BirthdayDto(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@@ -323,7 +300,6 @@ data class QttrRowDto(
|
|||||||
val playerName: String = "",
|
val playerName: String = "",
|
||||||
val clubName: String = "",
|
val clubName: String = "",
|
||||||
val currentQttr: Int? = null,
|
val currentQttr: Int? = null,
|
||||||
val currentTtr: Int? = null,
|
|
||||||
val previousQttr: Int? = null,
|
val previousQttr: Int? = null,
|
||||||
val birthdate: String? = null,
|
val birthdate: String? = null,
|
||||||
)
|
)
|
||||||
@@ -352,7 +328,6 @@ data class MemberDto(
|
|||||||
val editable: Boolean = false,
|
val editable: Boolean = false,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
val showBirthday: Boolean = false,
|
|
||||||
val loginRoles: List<String> = emptyList(),
|
val loginRoles: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
data class MembersResponse(
|
data class MembersResponse(
|
||||||
@@ -615,21 +590,6 @@ interface ApiService {
|
|||||||
@GET("/api/termine")
|
@GET("/api/termine")
|
||||||
suspend fun termine(): Response<TermineResponse>
|
suspend fun termine(): Response<TermineResponse>
|
||||||
|
|
||||||
@GET("/api/termine-manage")
|
|
||||||
suspend fun termineManage(): Response<TermineManageResponse>
|
|
||||||
|
|
||||||
@POST("/api/termine-manage")
|
|
||||||
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
|
|
||||||
|
|
||||||
@DELETE("/api/termine-manage")
|
|
||||||
suspend fun deleteTermin(
|
|
||||||
@Query("datum") datum: String,
|
|
||||||
@Query("uhrzeit") uhrzeit: String = "",
|
|
||||||
@Query("titel") titel: String,
|
|
||||||
@Query("beschreibung") beschreibung: String = "",
|
|
||||||
@Query("kategorie") kategorie: String = "Sonstiges",
|
|
||||||
): Response<AuthMessageResponse>
|
|
||||||
|
|
||||||
@GET("/api/spielplan")
|
@GET("/api/spielplan")
|
||||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||||
|
|
||||||
@@ -675,18 +635,12 @@ interface ApiService {
|
|||||||
@POST("/api/cms/save-csv")
|
@POST("/api/cms/save-csv")
|
||||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||||
|
|
||||||
@POST("/api/cms/import-spielplan")
|
|
||||||
suspend fun importSpielplan(): Response<AuthMessageResponse>
|
|
||||||
|
|
||||||
@POST("/api/membership/generate-pdf")
|
@POST("/api/membership/generate-pdf")
|
||||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||||
|
|
||||||
@Streaming
|
@Streaming
|
||||||
@GET
|
@GET
|
||||||
suspend fun downloadMembershipPdf(
|
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
|
||||||
@Url downloadUrl: String,
|
|
||||||
@retrofit2.http.Header("X-Membership-Download-Token") downloadToken: String? = null,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@POST("/api/auth/login")
|
@POST("/api/auth/login")
|
||||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||||
@@ -745,9 +699,6 @@ interface ApiService {
|
|||||||
@GET("/api/mitgliederbereich/qttr")
|
@GET("/api/mitgliederbereich/qttr")
|
||||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||||
|
|
||||||
@GET("/api/mitgliederbereich/live-ticker")
|
|
||||||
suspend fun liveTicker(): Response<LiveTickerResponse>
|
|
||||||
|
|
||||||
@GET("/api/members")
|
@GET("/api/members")
|
||||||
suspend fun members(): Response<MembersResponse>
|
suspend fun members(): Response<MembersResponse>
|
||||||
|
|
||||||
@@ -762,7 +713,6 @@ interface ApiService {
|
|||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
val showBirthday: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt
Executable file → Normal file
2
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt
Executable file → Normal file
2
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt
Executable file → Normal file
@@ -19,7 +19,7 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ConnectivityMonitor @Inject constructor(
|
class ConnectivityMonitor @Inject constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val _online = MutableStateFlow(hasInternetAccess())
|
private val _online = MutableStateFlow(hasInternetAccess())
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/SessionRefresher.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt
Executable file → Normal file
2
android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt
Executable file → Normal file
2
android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt
Executable file → Normal file
@@ -21,7 +21,7 @@ class HarheimerMessagingService : FirebaseMessagingService() {
|
|||||||
override fun onNewToken(token: String) {
|
override fun onNewToken(token: String) {
|
||||||
super.onNewToken(token)
|
super.onNewToken(token)
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
pushTokenRepository.registerCurrentDevice()
|
pushTokenRepository.registerToken(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt
Executable file → Normal file
25
android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt
Executable file → Normal file
@@ -16,16 +16,14 @@ import de.harheimertc.R
|
|||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
|
||||||
object HarheimerNotifications {
|
object HarheimerNotifications {
|
||||||
// A new ID is intentional: Android does not allow an app update to raise
|
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
|
||||||
// the importance of an already-created notification channel.
|
|
||||||
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates_v2"
|
|
||||||
|
|
||||||
fun createChannels(context: Context) {
|
fun createChannels(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
DEFAULT_CHANNEL_ID,
|
DEFAULT_CHANNEL_ID,
|
||||||
"Harheimer TC",
|
"Harheimer TC",
|
||||||
NotificationManager.IMPORTANCE_HIGH,
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
).apply {
|
).apply {
|
||||||
description = "Benachrichtigungen des Harheimer TC"
|
description = "Benachrichtigungen des Harheimer TC"
|
||||||
}
|
}
|
||||||
@@ -49,16 +47,12 @@ object HarheimerNotifications {
|
|||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(message)
|
.setContentText(message)
|
||||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
return try {
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
return true
|
||||||
true
|
|
||||||
} catch (_: SecurityException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||||
@@ -78,14 +72,7 @@ object HarheimerNotifications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
"news" -> 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
|
else -> Destinations.Home.route
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt
Executable file → Normal file
144
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Executable file → Normal file
144
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Executable file → Normal file
@@ -10,8 +10,6 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
|||||||
import de.harheimertc.data.SaveCsvRequest
|
import de.harheimertc.data.SaveCsvRequest
|
||||||
import de.harheimertc.data.SaveCsvResponse
|
import de.harheimertc.data.SaveCsvResponse
|
||||||
import de.harheimertc.data.SecureOfflineCache
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
import de.harheimertc.data.SpielplanResponse
|
|
||||||
import de.harheimertc.data.TerminDto
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CmsRepository @Inject constructor(
|
class CmsRepository @Inject constructor(
|
||||||
@@ -85,93 +83,6 @@ class CmsRepository @Inject constructor(
|
|||||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
|
|
||||||
val response = api.termineManage()
|
|
||||||
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
|
|
||||||
response.body()?.termine.orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
|
||||||
val response = api.saveTermin(request)
|
|
||||||
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
|
|
||||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
|
||||||
val response = api.deleteTermin(
|
|
||||||
datum = request.datum,
|
|
||||||
uhrzeit = request.uhrzeit.orEmpty(),
|
|
||||||
titel = request.titel,
|
|
||||||
beschreibung = request.beschreibung.orEmpty(),
|
|
||||||
kategorie = request.kategorie ?: "Sonstiges",
|
|
||||||
)
|
|
||||||
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
|
|
||||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
|
|
||||||
val response = api.mannschaften(season)
|
|
||||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
|
||||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
|
||||||
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
|
|
||||||
CmsMannschaftRow(
|
|
||||||
mannschaft = values[0],
|
|
||||||
liga = values[1],
|
|
||||||
staffelleiter = values[2],
|
|
||||||
telefon = values[3],
|
|
||||||
heimspieltag = values[4],
|
|
||||||
spielsystem = values[5],
|
|
||||||
mannschaftsfuehrer = values[6],
|
|
||||||
spieler = values[7],
|
|
||||||
informationenLink = values[8],
|
|
||||||
letzteAktualisierung = values[9],
|
|
||||||
spielplanMannschaftId = values.getOrElse(10) { "" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
|
|
||||||
val response = api.mannschaftenSeasons()
|
|
||||||
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
|
||||||
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
|
|
||||||
val response = api.saveCsv(
|
|
||||||
SaveCsvRequest(
|
|
||||||
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
|
|
||||||
content = rows.toMannschaftenCsv(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
|
|
||||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
|
||||||
val response = api.importSpielplan()
|
|
||||||
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
|
|
||||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
|
||||||
val response = api.spielplan(season)
|
|
||||||
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
|
||||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
|
|
||||||
val response = api.saveCsv(
|
|
||||||
SaveCsvRequest(
|
|
||||||
filename = "spielplan.csv",
|
|
||||||
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
|
|
||||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun users(): Result<CmsUsersResponse> =
|
suspend fun users(): Result<CmsUsersResponse> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -377,61 +288,6 @@ class CmsRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CmsMannschaftRow(
|
|
||||||
val mannschaft: String = "",
|
|
||||||
val liga: String = "",
|
|
||||||
val staffelleiter: String = "",
|
|
||||||
val telefon: String = "",
|
|
||||||
val heimspieltag: String = "",
|
|
||||||
val spielsystem: String = "",
|
|
||||||
val mannschaftsfuehrer: String = "",
|
|
||||||
val spieler: String = "",
|
|
||||||
val informationenLink: String = "",
|
|
||||||
val letzteAktualisierung: String = "",
|
|
||||||
val spielplanMannschaftId: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
|
||||||
val header = listOf(
|
|
||||||
"Mannschaft",
|
|
||||||
"Liga",
|
|
||||||
"Staffelleiter",
|
|
||||||
"Telefon",
|
|
||||||
"Heimspieltag",
|
|
||||||
"Spielsystem",
|
|
||||||
"Mannschaftsführer",
|
|
||||||
"Spieler",
|
|
||||||
"Weitere Informationen Link",
|
|
||||||
"Letzte Aktualisierung",
|
|
||||||
"Spielplan Mannschaft ID",
|
|
||||||
).toCsvRow()
|
|
||||||
val rows = map { row ->
|
|
||||||
listOf(
|
|
||||||
row.mannschaft,
|
|
||||||
row.liga,
|
|
||||||
row.staffelleiter,
|
|
||||||
row.telefon,
|
|
||||||
row.heimspieltag,
|
|
||||||
row.spielsystem,
|
|
||||||
row.mannschaftsfuehrer,
|
|
||||||
row.spieler,
|
|
||||||
row.informationenLink,
|
|
||||||
row.letzteAktualisierung,
|
|
||||||
row.spielplanMannschaftId,
|
|
||||||
).toCsvRow()
|
|
||||||
}
|
|
||||||
return listOf(header).plus(rows).joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<String>.toCsvRow(delimiter: String = ","): String =
|
|
||||||
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
|
|
||||||
|
|
||||||
private fun String.csvEscape(delimiter: String): String {
|
|
||||||
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
|
||||||
val escaped = replace("\"", "\"\"")
|
|
||||||
return if (needsQuotes) "\"$escaped\"" else escaped
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<List<String>> =
|
private fun parseCsv(csv: String): List<List<String>> =
|
||||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||||
|
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt
Executable file → Normal file
@@ -1,17 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt
Executable file → Normal file
38
android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt
Executable file → Normal file
38
android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt
Executable file → Normal file
@@ -9,7 +9,7 @@ import java.io.File
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
data class MembershipDocument(val message: String, val uri: String? = null)
|
data class MembershipDocument(val message: String, val uri: String)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class MembershipRepository @Inject constructor(
|
class MembershipRepository @Inject constructor(
|
||||||
@@ -18,33 +18,21 @@ class MembershipRepository @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
||||||
val response = api.generateMembershipPdf(request)
|
val response = api.generateMembershipPdf(request)
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
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")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
||||||
val uri = body.downloadUrl?.let { downloadUrl ->
|
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
|
||||||
runCatching {
|
val documentResponse = api.downloadMembershipPdf(downloadUrl)
|
||||||
val documentResponse = api.downloadMembershipPdf(downloadUrl, body.downloadToken)
|
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
val file = File(directory, "beitrittserklaerung.pdf")
|
||||||
val file = File(directory, "beitrittserklaerung.pdf")
|
documentResponse.body()?.byteStream()?.use { input ->
|
||||||
documentResponse.body()?.byteStream()?.use { input ->
|
file.outputStream().use { output -> input.copyTo(output) }
|
||||||
file.outputStream().use { output -> input.copyTo(output) }
|
} ?: error("Leere PDF-Antwort")
|
||||||
} ?: error("Leere PDF-Antwort")
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||||
FileProvider.getUriForFile(context, "${context.packageName}.files", file).toString()
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
MembershipDocument(
|
MembershipDocument(
|
||||||
message = if (uri == null) {
|
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
|
||||||
"${body.message ?: "Mitgliedschaftsantrag erfolgreich übermittelt."} Das PDF konnte nicht auf diesem Gerät gespeichert werden."
|
uri = uri.toString(),
|
||||||
} else {
|
|
||||||
body.message ?: "Beitrittsformular erfolgreich erstellt."
|
|
||||||
},
|
|
||||||
uri = uri,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt
Executable file → Normal file
36
android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt
Executable file → Normal file
36
android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt
Executable file → Normal file
@@ -19,10 +19,6 @@ data class NotificationPreferences(
|
|||||||
val birthdays: Boolean = false,
|
val birthdays: Boolean = false,
|
||||||
val newContactRequest: Boolean = false,
|
val newContactRequest: Boolean = false,
|
||||||
val newUserRegistration: 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 selectedTeamSlugs: Set<String> = emptySet(),
|
||||||
val selectedTeamSeason: String? = null,
|
val selectedTeamSeason: String? = null,
|
||||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||||
@@ -47,10 +43,6 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, 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(),
|
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||||
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||||
@@ -77,10 +69,6 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||||
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||||
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
.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)
|
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||||
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||||
@@ -88,12 +76,13 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||||
|
saveLocal(settings)
|
||||||
return runCatching {
|
return runCatching {
|
||||||
retryOnNetworkFailure {
|
retryOnNetworkFailure {
|
||||||
val response = api.updateNotificationSettings(settings.toDto())
|
val response = api.updateNotificationSettings(settings.toDto())
|
||||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||||
requireLiveNotificationSettingsSaved(settings, saved)
|
saveLocal(saved)
|
||||||
saved
|
saved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,25 +98,12 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
const val KEY_BIRTHDAYS = "birthdays"
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||||
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
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_SLUGS = "selected_team_slugs"
|
||||||
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
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(
|
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||||
newNews = newNews,
|
newNews = newNews,
|
||||||
newEvents = newEvents,
|
newEvents = newEvents,
|
||||||
@@ -138,10 +114,6 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
|
|||||||
birthdays = birthdays,
|
birthdays = birthdays,
|
||||||
newContactRequest = newContactRequest,
|
newContactRequest = newContactRequest,
|
||||||
newUserRegistration = newUserRegistration,
|
newUserRegistration = newUserRegistration,
|
||||||
ownTtrChanges = ownTtrChanges,
|
|
||||||
liveTickerStarts = liveTickerStarts,
|
|
||||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
|
||||||
liveTickerFinished = liveTickerFinished,
|
|
||||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||||
selectedTeamSeason = selectedTeamSeason,
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
notificationTime = notificationTime,
|
notificationTime = notificationTime,
|
||||||
@@ -157,10 +129,6 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
|
|||||||
birthdays = birthdays,
|
birthdays = birthdays,
|
||||||
newContactRequest = newContactRequest,
|
newContactRequest = newContactRequest,
|
||||||
newUserRegistration = newUserRegistration,
|
newUserRegistration = newUserRegistration,
|
||||||
ownTtrChanges = ownTtrChanges,
|
|
||||||
liveTickerStarts = liveTickerStarts,
|
|
||||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
|
||||||
liveTickerFinished = liveTickerFinished,
|
|
||||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||||
selectedTeamSeason = selectedTeamSeason,
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
notificationTime = notificationTime,
|
notificationTime = notificationTime,
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt
Executable file → Normal file
12
android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt
Executable file → Normal file
12
android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt
Executable file → Normal file
@@ -1,7 +1,6 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.firebase.installations.FirebaseInstallations
|
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
@@ -16,24 +15,19 @@ class PushTokenRepository @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||||
val token = FirebaseMessaging.getInstance().token.await()
|
val token = FirebaseMessaging.getInstance().token.await()
|
||||||
val installationId = FirebaseInstallations.getInstance().id.await()
|
registerToken(token).getOrThrow()
|
||||||
registerToken(token, installationId).getOrThrow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching {
|
suspend fun registerToken(token: String): Result<Unit> = runCatching {
|
||||||
if (token.isBlank()) return@runCatching
|
if (token.isBlank()) return@runCatching
|
||||||
retryOnNetworkFailure {
|
retryOnNetworkFailure {
|
||||||
val response = api.registerPushToken(
|
val response = api.registerPushToken(
|
||||||
PushTokenRequest(
|
PushTokenRequest(
|
||||||
token = token,
|
token = token,
|
||||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||||
installationId = installationId,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
|
||||||
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 ->
|
}.onFailure { error ->
|
||||||
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt
Executable file → Normal file
9
android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt
Executable file → Normal file
9
android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt
Executable file → Normal file
@@ -80,8 +80,8 @@ private fun CompactNavigation(
|
|||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
val routeSection = menuSection(selectedRoute)
|
val routeSection = menuSection(selectedRoute)
|
||||||
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
val section = sectionOverride.value ?: routeSection
|
val section = routeSection ?: sectionOverride.value
|
||||||
val subItems = submenu(section, navigationState)
|
val subItems = submenu(section, navigationState)
|
||||||
|
|
||||||
val mainScroll = rememberScrollState()
|
val mainScroll = rememberScrollState()
|
||||||
@@ -422,7 +422,6 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.CmsStartseite.route,
|
Destinations.CmsStartseite.route,
|
||||||
Destinations.CmsInhalte.route,
|
Destinations.CmsInhalte.route,
|
||||||
Destinations.CmsVereinsmeisterschaften.route,
|
Destinations.CmsVereinsmeisterschaften.route,
|
||||||
Destinations.CmsNews.route,
|
|
||||||
Destinations.CmsSportbetrieb.route,
|
Destinations.CmsSportbetrieb.route,
|
||||||
Destinations.CmsMitgliederverwaltung.route,
|
Destinations.CmsMitgliederverwaltung.route,
|
||||||
Destinations.CmsNewsletter.route,
|
Destinations.CmsNewsletter.route,
|
||||||
@@ -472,7 +471,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
MenuSection.INTERN -> buildList {
|
MenuSection.INTERN -> buildList {
|
||||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||||
add(MenuTarget("TTR / QTTR", Destinations.Qttr.route))
|
add(MenuTarget("QTTR", Destinations.Qttr.route))
|
||||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||||
@@ -485,7 +484,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||||
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||||
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||||
add(MenuTarget("News", Destinations.CmsNews.route))
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||||
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||||
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt
Executable file → Normal file
@@ -1,137 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/LoadingState.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/components/RichTextUtils.kt
Executable file → Normal file
1
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Executable file → Normal file
1
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Executable file → Normal file
@@ -48,7 +48,6 @@ sealed class Destinations(val route: String) {
|
|||||||
object CmsStartseite : Destinations("cms/startseite")
|
object CmsStartseite : Destinations("cms/startseite")
|
||||||
object CmsInhalte : Destinations("cms/inhalte")
|
object CmsInhalte : Destinations("cms/inhalte")
|
||||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||||
object CmsNews : Destinations("cms/news")
|
|
||||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||||
object CmsNewsletter : Destinations("cms/newsletter")
|
object CmsNewsletter : Destinations("cms/newsletter")
|
||||||
|
|||||||
142
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Executable file → Normal file
142
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Executable file → Normal file
@@ -2,9 +2,7 @@ package de.harheimertc.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
@@ -12,8 +10,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -26,7 +22,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import de.harheimertc.ui.components.AppNavigationHeader
|
import de.harheimertc.ui.components.AppNavigationHeader
|
||||||
import de.harheimertc.ui.components.LiveTickerBanner
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(
|
fun NavGraph(
|
||||||
@@ -46,13 +41,8 @@ fun NavGraph(
|
|||||||
LaunchedEffect(currentRoute) {
|
LaunchedEffect(currentRoute) {
|
||||||
navigationViewModel.refreshSession()
|
navigationViewModel.refreshSession()
|
||||||
}
|
}
|
||||||
LaunchedEffect(navigationState.loggedIn) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
navigationViewModel.setLiveTickerActive(navigationState.loggedIn)
|
val persistentNavigation = maxWidth >= 600.dp
|
||||||
}
|
|
||||||
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()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
navigationState.connectionNote?.let { message ->
|
navigationState.connectionNote?.let { message ->
|
||||||
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -75,16 +65,10 @@ fun NavGraph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
webTabletNavigation = tabletNavigation,
|
webTabletNavigation = true,
|
||||||
navigationState = navigationState,
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (navigationState.loggedIn) {
|
|
||||||
LiveTickerBanner(
|
|
||||||
matches = navigationState.liveTickerMatches,
|
|
||||||
loaded = navigationState.liveTickerLoaded,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
@@ -343,141 +327,43 @@ fun NavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsStartseite.route) {
|
composable(Destinations.CmsStartseite.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsInhalte.route) {
|
composable(Destinations.CmsInhalte.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(Destinations.CmsNews.route) {
|
|
||||||
RequireCmsAccess(
|
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsSportbetrieb.route) {
|
composable(Destinations.CmsSportbetrieb.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsMitgliederverwaltung.route) {
|
composable(Destinations.CmsMitgliederverwaltung.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsNewsletter.route) {
|
composable(Destinations.CmsNewsletter.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessNewsletter,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsContactRequests.route) {
|
composable(Destinations.CmsContactRequests.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessContactRequests,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsEinstellungen.route) {
|
composable(Destinations.CmsEinstellungen.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsBenutzer.route) {
|
composable(Destinations.CmsBenutzer.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessFullCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable(Destinations.Cms.route) {
|
composable(Destinations.Cms.route) {
|
||||||
RequireCmsAccess(
|
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
||||||
allowed = navigationState.canAccessCms,
|
|
||||||
navController = navController,
|
|
||||||
) {
|
|
||||||
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RequireCmsAccess(
|
|
||||||
allowed: Boolean,
|
|
||||||
navController: NavHostController,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
if (allowed) {
|
|
||||||
content()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Kein Zugriff auf diesen Bereich.",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = Color(0xFF7F1D1D),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Bitte mit einem berechtigten Konto anmelden.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Color(0xFF7C2D12),
|
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
|
||||||
)
|
|
||||||
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
|
|
||||||
Text("Zum Login")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun NavHostController.navigateTopLevel(route: String) {
|
private fun NavHostController.navigateTopLevel(route: String) {
|
||||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||||
route != Destinations.Spielsysteme.route
|
route != Destinations.Spielsysteme.route
|
||||||
|
|||||||
31
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt
Executable file → Normal file
31
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt
Executable file → Normal file
@@ -4,20 +4,15 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.data.ConnectivityMonitor
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
import de.harheimertc.data.LiveTickerMatchDto
|
|
||||||
import de.harheimertc.repositories.AuthRepository
|
import de.harheimertc.repositories.AuthRepository
|
||||||
import de.harheimertc.repositories.GalleryRepository
|
import de.harheimertc.repositories.GalleryRepository
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
import de.harheimertc.repositories.LiveTickerRepository
|
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
import de.harheimertc.repositories.PushTokenRepository
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -27,8 +22,6 @@ data class NavigationUiState(
|
|||||||
val loggedIn: Boolean = false,
|
val loggedIn: Boolean = false,
|
||||||
val roles: Set<String> = emptySet(),
|
val roles: Set<String> = emptySet(),
|
||||||
val connectionNote: String? = null,
|
val connectionNote: String? = null,
|
||||||
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
|
|
||||||
val liveTickerLoaded: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val isAdmin: Boolean get() = "admin" in roles
|
val isAdmin: Boolean get() = "admin" in roles
|
||||||
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||||
@@ -46,12 +39,9 @@ class NavigationViewModel @Inject constructor(
|
|||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val connectivityMonitor: ConnectivityMonitor,
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
private val pushTokenRepository: PushTokenRepository,
|
private val pushTokenRepository: PushTokenRepository,
|
||||||
private val liveTickerRepository: LiveTickerRepository,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NavigationUiState())
|
private val _state = MutableStateFlow(NavigationUiState())
|
||||||
val state: StateFlow<NavigationUiState> = _state
|
val state: StateFlow<NavigationUiState> = _state
|
||||||
private var tickerJob: Job? = null
|
|
||||||
private var tickerActive = false
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadNavigationData()
|
loadNavigationData()
|
||||||
@@ -80,8 +70,6 @@ class NavigationViewModel @Inject constructor(
|
|||||||
loggedIn = loggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = status.navigationRoles(),
|
roles = status.navigationRoles(),
|
||||||
connectionNote = null,
|
connectionNote = null,
|
||||||
liveTickerMatches = _state.value.liveTickerMatches,
|
|
||||||
liveTickerLoaded = _state.value.liveTickerLoaded,
|
|
||||||
)
|
)
|
||||||
if (loggedIn) registerPushToken()
|
if (loggedIn) registerPushToken()
|
||||||
}
|
}
|
||||||
@@ -101,24 +89,6 @@ class NavigationViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
private fun registerPushToken() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
pushTokenRepository.registerCurrentDevice()
|
pushTokenRepository.registerCurrentDevice()
|
||||||
@@ -126,7 +96,6 @@ class NavigationViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun logout(onComplete: () -> Unit = {}) {
|
fun logout(onComplete: () -> Unit = {}) {
|
||||||
setLiveTickerActive(false)
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
loginRepository.logout()
|
loginRepository.logout()
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsBenutzerScreens.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsBenutzerScreens.kt
Executable file → Normal file
34
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Executable file → Normal file
34
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt
Executable file → Normal file
@@ -132,22 +132,15 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
|
|
||||||
items(state.news) { news ->
|
items(state.news) { news ->
|
||||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||||
NewsListItem(
|
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
||||||
news = news,
|
id?.let {
|
||||||
canWrite = canWrite,
|
selection = if (sel) selection + it else selection - it
|
||||||
selected = selected,
|
}
|
||||||
onSelect = { id, sel ->
|
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
||||||
id?.let {
|
|
||||||
selection = if (sel) selection + it else selection - it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onEdit = { openEdit(news) },
|
|
||||||
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bulk action bar
|
// bulk action bar
|
||||||
if (canWrite && selection.isNotEmpty()) {
|
if (selection.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||||
@@ -262,7 +255,6 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NewsListItem(
|
private fun NewsListItem(
|
||||||
news: NewsDto,
|
news: NewsDto,
|
||||||
canWrite: Boolean,
|
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||||
onEdit: (NewsDto) -> Unit,
|
onEdit: (NewsDto) -> Unit,
|
||||||
@@ -272,11 +264,7 @@ private fun NewsListItem(
|
|||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
Checkbox(
|
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
||||||
checked = selected,
|
|
||||||
onCheckedChange = { onSelect(news.id, it) },
|
|
||||||
enabled = canWrite,
|
|
||||||
)
|
|
||||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||||
if (news.isPublic) {
|
if (news.isPublic) {
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||||
@@ -309,11 +297,9 @@ private fun NewsListItem(
|
|||||||
Text("Aktualisiert: ${news.updated}")
|
Text("Aktualisiert: ${news.updated}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canWrite) {
|
Row {
|
||||||
Row {
|
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
624
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Executable file → Normal file
624
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Executable file → Normal file
@@ -1,10 +1,8 @@
|
|||||||
package de.harheimertc.ui.screens.cms
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.app.DatePickerDialog
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -38,8 +36,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -52,12 +48,10 @@ import de.harheimertc.data.NewsletterGroupDto
|
|||||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.PasswordResetStepDto
|
import de.harheimertc.data.PasswordResetStepDto
|
||||||
import de.harheimertc.data.TerminDto
|
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
import de.harheimertc.ui.components.LoadingState
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import de.harheimertc.repositories.CmsMannschaftRow
|
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
@@ -67,7 +61,6 @@ import de.harheimertc.ui.theme.Primary100
|
|||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -440,428 +433,118 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
@Composable
|
@Composable
|
||||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val context = LocalContext.current
|
val config = state.config
|
||||||
var activeTab by remember { mutableStateOf("termine") }
|
var ortName by remember { mutableStateOf("") }
|
||||||
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
|
var ortStrasse by remember { mutableStateOf("") }
|
||||||
var spielplanCsv by remember { mutableStateOf("") }
|
var ortPlz by remember { mutableStateOf("") }
|
||||||
var spielplanEditorOpen by remember { mutableStateOf(false) }
|
var ortOrt by remember { mutableStateOf("") }
|
||||||
var terminDialogOpen by remember { mutableStateOf(false) }
|
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
||||||
var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
|
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
||||||
var terminDatum by remember { mutableStateOf("") }
|
|
||||||
var terminUhrzeit by remember { mutableStateOf("") }
|
|
||||||
var terminTitel by remember { mutableStateOf("") }
|
|
||||||
var terminBeschreibung by remember { mutableStateOf("") }
|
|
||||||
var terminKategorie by remember { mutableStateOf("Sonstiges") }
|
|
||||||
var terminKategorieOpen by remember { mutableStateOf(false) }
|
|
||||||
val tabs = listOf(
|
|
||||||
"termine" to "Termine",
|
|
||||||
"mannschaften" to "Mannschaften",
|
|
||||||
"spielplaene" to "Spielpläne",
|
|
||||||
)
|
|
||||||
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
|
||||||
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
|
||||||
}
|
|
||||||
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(config) {
|
||||||
viewModel.loadSportbetrieb()
|
config?.let {
|
||||||
}
|
ortName = it.training.ort.name
|
||||||
|
ortStrasse = it.training.ort.strasse
|
||||||
LaunchedEffect(state.sportMannschaften) {
|
ortPlz = it.training.ort.plz
|
||||||
mannschaften.clear()
|
ortOrt = it.training.ort.ort
|
||||||
mannschaften.addAll(state.sportMannschaften)
|
trainingTimes.clear()
|
||||||
}
|
trainingTimes.addAll(it.training.zeiten)
|
||||||
|
trainers.clear()
|
||||||
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
trainers.addAll(it.trainer)
|
||||||
spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun openTerminDialog(termin: TerminDto?) {
|
|
||||||
editingTermin = termin
|
|
||||||
terminDatum = termin?.datum.orEmpty()
|
|
||||||
terminUhrzeit = termin?.uhrzeit.orEmpty()
|
|
||||||
terminTitel = termin?.titel.orEmpty()
|
|
||||||
terminBeschreibung = termin?.beschreibung.orEmpty()
|
|
||||||
terminKategorie = termin?.kategorie ?: "Sonstiges"
|
|
||||||
terminDialogOpen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun openDatePicker() {
|
|
||||||
val calendar = Calendar.getInstance()
|
|
||||||
runCatching {
|
|
||||||
val parts = terminDatum.split("-")
|
|
||||||
if (parts.size == 3) {
|
|
||||||
calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DatePickerDialog(
|
|
||||||
context,
|
|
||||||
{ _, year, month, day ->
|
|
||||||
terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day)
|
|
||||||
},
|
|
||||||
calendar.get(Calendar.YEAR),
|
|
||||||
calendar.get(Calendar.MONTH),
|
|
||||||
calendar.get(Calendar.DAY_OF_MONTH),
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") {
|
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
||||||
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
when {
|
||||||
item {
|
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||||
Row(
|
else -> {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
item {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
Button(
|
||||||
) {
|
onClick = {
|
||||||
tabs.forEach { (id, label) ->
|
viewModel.saveConfig(
|
||||||
if (activeTab == id) {
|
config.copy(
|
||||||
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
training = config.training.copy(
|
||||||
} else {
|
ort = config.training.ort.copy(
|
||||||
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
name = ortName,
|
||||||
|
strasse = ortStrasse,
|
||||||
|
plz = ortPlz,
|
||||||
|
ort = ortOrt,
|
||||||
|
),
|
||||||
|
zeiten = trainingTimes.toList(),
|
||||||
|
),
|
||||||
|
trainer = trainers.toList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = !state.saving,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (state.saving) "Speichert..." else "Speichern")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
item {
|
||||||
}
|
DataCard("Trainingsort") {
|
||||||
|
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||||
if (!state.sportLoading) {
|
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
||||||
when (activeTab) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
"termine" -> {
|
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||||
item {
|
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainingszeiten") {
|
||||||
|
trainingTimes.forEachIndexed { index, zeit ->
|
||||||
|
TrainingTimeEditorCard(
|
||||||
|
zeit = zeit,
|
||||||
|
onChange = { updated -> trainingTimes[index] = updated },
|
||||||
|
onRemove = { trainingTimes.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { openTerminDialog(null) },
|
onClick = {
|
||||||
enabled = !state.sportSaving,
|
trainingTimes.add(
|
||||||
|
de.harheimertc.data.TrainingTimeDto(
|
||||||
|
id = "training-${System.currentTimeMillis()}",
|
||||||
|
tag = "Montag",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text("Termin hinzufügen")
|
Text("Trainingszeit hinzufügen")
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.sportTermine.isEmpty()) {
|
|
||||||
item { EmptyCard("Keine Termine gefunden.") }
|
|
||||||
}
|
|
||||||
items(state.sportTermine.size) { index ->
|
|
||||||
val termin = state.sportTermine[index]
|
|
||||||
DataCard(termin.titel.ifBlank { "Termin" }) {
|
|
||||||
InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" "))
|
|
||||||
InfoRow("Kategorie", termin.kategorie ?: "Sonstiges")
|
|
||||||
if (!termin.beschreibung.isNullOrBlank()) {
|
|
||||||
Text(termin.beschreibung, color = Accent700)
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) {
|
|
||||||
Text("Bearbeiten")
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = { viewModel.deleteSportTermin(termin) },
|
|
||||||
enabled = !state.sportSaving,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text("Löschen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"mannschaften" -> {
|
item {
|
||||||
item {
|
DataCard("Trainer") {
|
||||||
if (state.sportMannschaftenSeasons.isNotEmpty()) {
|
trainers.forEachIndexed { index, trainer ->
|
||||||
DataCard("Saison") {
|
TrainerEditorCard(
|
||||||
Row(
|
trainer = trainer,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onChange = { updated -> trainers[index] = updated },
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
onRemove = { trainers.removeAt(index) },
|
||||||
) {
|
)
|
||||||
state.sportMannschaftenSeasons.forEach { season ->
|
|
||||||
if (season == state.sportMannschaftenSeason) {
|
|
||||||
Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) }
|
|
||||||
} else {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { viewModel.loadSportMannschaftenSeason(season) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text(season)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Button(
|
||||||
item {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text("Hinzufügen")
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
|
|
||||||
enabled = !state.sportSaving,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mannschaften.isEmpty()) {
|
|
||||||
item { EmptyCard("Keine Mannschaften gefunden.") }
|
|
||||||
}
|
|
||||||
items(mannschaften.size) { index ->
|
|
||||||
MannschaftEditorCard(
|
|
||||||
row = mannschaften[index],
|
|
||||||
spielplanTeams = spielplanTeamOptions,
|
|
||||||
onChange = { updated -> mannschaften[index] = updated },
|
|
||||||
onRemove = { mannschaften.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"spielplaene" -> {
|
|
||||||
item {
|
|
||||||
DataCard("Vereins-Spielplan (CSV)") {
|
|
||||||
val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" }
|
|
||||||
val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv"
|
|
||||||
InfoRow("Datei", fileName)
|
|
||||||
InfoRow("Saison", seasonLabel)
|
|
||||||
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.importSportSpielplan() },
|
|
||||||
enabled = !state.sportSaving,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text(if (state.sportSaving) "Importiert..." else "Von myTischtennis importieren")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { viewModel.loadSportbetrieb() },
|
|
||||||
enabled = !state.sportSaving,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text("Neu laden")
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = { spielplanEditorOpen = true },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text("CSV bearbeiten")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { FormMessages(state.error, state.message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terminDialogOpen) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { terminDialogOpen = false },
|
|
||||||
title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(terminDatum.ifBlank { "Datum auswählen" })
|
|
||||||
}
|
|
||||||
OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(terminKategorie.ifBlank { "Kategorie auswählen" })
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) {
|
|
||||||
terminKategorien.forEach { kategorie ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(kategorie) },
|
|
||||||
onClick = {
|
|
||||||
terminKategorie = kategorie
|
|
||||||
terminKategorieOpen = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
viewModel.saveSportTermin(
|
|
||||||
editingTermin,
|
|
||||||
TerminDto(
|
|
||||||
datum = terminDatum,
|
|
||||||
uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() },
|
|
||||||
titel = terminTitel,
|
|
||||||
beschreibung = terminBeschreibung.takeIf { it.isNotBlank() },
|
|
||||||
kategorie = terminKategorie.ifBlank { "Sonstiges" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
terminDialogOpen = false
|
|
||||||
},
|
|
||||||
enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(),
|
|
||||||
) {
|
|
||||||
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spielplanEditorOpen) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { spielplanEditorOpen = false },
|
|
||||||
title = { Text("Spielplan CSV bearbeiten") },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = spielplanCsv,
|
|
||||||
onValueChange = { spielplanCsv = it },
|
|
||||||
label = { Text("CSV mit Semikolon") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
minLines = 12,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val (headers, rows) = parseSportCsvText(spielplanCsv)
|
|
||||||
viewModel.saveSportSpielplan(headers, rows)
|
|
||||||
spielplanEditorOpen = false
|
|
||||||
},
|
|
||||||
enabled = !state.sportSaving && spielplanCsv.isNotBlank(),
|
|
||||||
) {
|
|
||||||
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MannschaftEditorCard(
|
|
||||||
row: CmsMannschaftRow,
|
|
||||||
spielplanTeams: List<ImportedSpielplanTeam>,
|
|
||||||
onChange: (CmsMannschaftRow) -> Unit,
|
|
||||||
onRemove: () -> Unit,
|
|
||||||
) {
|
|
||||||
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
|
|
||||||
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
|
||||||
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
if (spielplanTeams.isNotEmpty()) {
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text("Aus importierten Teams auswählen")
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
|
|
||||||
spielplanTeams.forEach { team ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(team.label) },
|
|
||||||
onClick = {
|
onClick = {
|
||||||
onChange(row.copy(spielplanMannschaftId = team.id))
|
trainers.add(
|
||||||
teamPickerOpen = false
|
de.harheimertc.data.TrainerDto(
|
||||||
|
id = "trainer-${System.currentTimeMillis()}",
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Trainer hinzufügen")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item { FormMessages(state.error, state.message) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
|
||||||
OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text("Entfernen")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class ImportedSpielplanTeam(val id: String, val label: String)
|
|
||||||
|
|
||||||
private fun importedSpielplanTeams(headers: List<String>, rows: List<List<String>>): List<ImportedSpielplanTeam> {
|
|
||||||
fun index(name: String) = headers.indexOf(name)
|
|
||||||
fun value(row: List<String>, column: String): String {
|
|
||||||
val columnIndex = index(column)
|
|
||||||
return row.getOrElse(columnIndex) { "" }.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
val teams = linkedMapOf<String, ImportedSpielplanTeam>()
|
|
||||||
listOf("Heim", "Gast").forEach { side ->
|
|
||||||
rows.forEach { row ->
|
|
||||||
val isHarheimer = value(row, "${side}VereinNr") == "43030" || value(row, "${side}VereinName") == "Harheimer TC"
|
|
||||||
val id = value(row, "${side}MannschaftId")
|
|
||||||
if (!isHarheimer || id.isBlank()) return@forEach
|
|
||||||
|
|
||||||
val teamName = value(row, "${side}Mannschaft")
|
|
||||||
val ageClass = value(row, "${side}MannschaftAltersklasse")
|
|
||||||
val teamNumber = value(row, "${side}MannschaftNr")
|
|
||||||
val label = listOf(teamName, ageClass, teamNumber.takeIf { it.isNotBlank() }?.let { "Nr. $it" })
|
|
||||||
.filterNotNull()
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.joinToString(" · ")
|
|
||||||
teams.putIfAbsent(id, ImportedSpielplanTeam(id, "$label [$id]"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return teams.values.sortedBy { it.label }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
|
|
||||||
if (headers.isEmpty()) return ""
|
|
||||||
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
|
|
||||||
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
|
|
||||||
if (lines.isEmpty()) return emptyList<String>() to emptyList()
|
|
||||||
val headers = parseDelimitedLine(lines.first(), ';')
|
|
||||||
val rows = lines.drop(1).map { parseDelimitedLine(it, ';') }
|
|
||||||
return headers to rows
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseDelimitedLine(line: String, delimiter: Char): List<String> {
|
|
||||||
val values = mutableListOf<String>()
|
|
||||||
val value = StringBuilder()
|
|
||||||
var quoted = false
|
|
||||||
var index = 0
|
|
||||||
while (index < line.length) {
|
|
||||||
when (val char = line[index]) {
|
|
||||||
'"' -> {
|
|
||||||
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
|
||||||
value.append('"')
|
|
||||||
index++
|
|
||||||
} else {
|
|
||||||
quoted = !quoted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delimiter -> if (quoted) value.append(char) else {
|
|
||||||
values += value.toString()
|
|
||||||
value.clear()
|
|
||||||
}
|
|
||||||
else -> value.append(char)
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
values += value.toString()
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.csvCell(delimiter: String): String {
|
|
||||||
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
|
||||||
val escaped = replace("\"", "\"\"")
|
|
||||||
return if (needsQuotes) "\"$escaped\"" else escaped
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
@@ -936,13 +619,11 @@ fun CmsNewsletterScreen(
|
|||||||
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
||||||
items(state.newsletters.size) { index ->
|
items(state.newsletters.size) { index ->
|
||||||
val item = state.newsletters[index]
|
val item = state.newsletters[index]
|
||||||
NewsletterCard(
|
NewsletterCard(item,
|
||||||
item,
|
|
||||||
canWrite = canWrite,
|
|
||||||
onEdit = { nl ->
|
onEdit = { nl ->
|
||||||
editingNewsletter = nl
|
editingNewsletter = nl
|
||||||
nlTitle = nl.title
|
nlTitle = nl.title
|
||||||
nlContent = nl.title
|
nlContent = nl.title ?: ""
|
||||||
nlType = "subscription"
|
nlType = "subscription"
|
||||||
nlTargetGroup = ""
|
nlTargetGroup = ""
|
||||||
nlSendToExternal = true
|
nlSendToExternal = true
|
||||||
@@ -956,9 +637,7 @@ fun CmsNewsletterScreen(
|
|||||||
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
||||||
items(state.newsletterGroups.size) { index ->
|
items(state.newsletterGroups.size) { index ->
|
||||||
val group = state.newsletterGroups[index]
|
val group = state.newsletterGroups[index]
|
||||||
NewsletterGroupCard(
|
NewsletterGroupCard(group,
|
||||||
group,
|
|
||||||
canWrite = canWrite,
|
|
||||||
onEdit = { g ->
|
onEdit = { g ->
|
||||||
editingGroup = g
|
editingGroup = g
|
||||||
grpName = g.name
|
grpName = g.name
|
||||||
@@ -1074,12 +753,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
var websiteVorname by remember { mutableStateOf("") }
|
var websiteVorname by remember { mutableStateOf("") }
|
||||||
var websiteNachname by remember { mutableStateOf("") }
|
var websiteNachname by remember { mutableStateOf("") }
|
||||||
var websiteEmail by remember { mutableStateOf("") }
|
var websiteEmail by remember { mutableStateOf("") }
|
||||||
var ortName by remember { mutableStateOf("") }
|
|
||||||
var ortStrasse by remember { mutableStateOf("") }
|
|
||||||
var ortPlz by remember { mutableStateOf("") }
|
|
||||||
var ortOrt by remember { mutableStateOf("") }
|
|
||||||
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
|
||||||
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
LaunchedEffect(config) {
|
||||||
config?.let {
|
config?.let {
|
||||||
@@ -1091,14 +764,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
websiteVorname = it.website.verantwortlicher.vorname
|
websiteVorname = it.website.verantwortlicher.vorname
|
||||||
websiteNachname = it.website.verantwortlicher.nachname
|
websiteNachname = it.website.verantwortlicher.nachname
|
||||||
websiteEmail = it.website.verantwortlicher.email
|
websiteEmail = it.website.verantwortlicher.email
|
||||||
ortName = it.training.ort.name
|
|
||||||
ortStrasse = it.training.ort.strasse
|
|
||||||
ortPlz = it.training.ort.plz
|
|
||||||
ortOrt = it.training.ort.ort
|
|
||||||
trainingTimes.clear()
|
|
||||||
trainingTimes.addAll(it.training.zeiten)
|
|
||||||
trainers.clear()
|
|
||||||
trainers.addAll(it.trainer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1125,16 +790,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
email = websiteEmail,
|
email = websiteEmail,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
training = config.training.copy(
|
|
||||||
ort = config.training.ort.copy(
|
|
||||||
name = ortName,
|
|
||||||
strasse = ortStrasse,
|
|
||||||
plz = ortPlz,
|
|
||||||
ort = ortOrt,
|
|
||||||
),
|
|
||||||
zeiten = trainingTimes.toList(),
|
|
||||||
),
|
|
||||||
trainer = trainers.toList(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1167,63 +822,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
DataCard("Trainingsort") {
|
|
||||||
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainingszeiten") {
|
|
||||||
trainingTimes.forEachIndexed { index, zeit ->
|
|
||||||
TrainingTimeEditorCard(
|
|
||||||
zeit = zeit,
|
|
||||||
onChange = { updated -> trainingTimes[index] = updated },
|
|
||||||
onRemove = { trainingTimes.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainingTimes.add(
|
|
||||||
de.harheimertc.data.TrainingTimeDto(
|
|
||||||
id = "training-${System.currentTimeMillis()}",
|
|
||||||
tag = "Montag",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainingszeit hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainer") {
|
|
||||||
trainers.forEachIndexed { index, trainer ->
|
|
||||||
TrainerEditorCard(
|
|
||||||
trainer = trainer,
|
|
||||||
onChange = { updated -> trainers[index] = updated },
|
|
||||||
onRemove = { trainers.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainers.add(
|
|
||||||
de.harheimertc.data.TrainerDto(
|
|
||||||
id = "trainer-${System.currentTimeMillis()}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainer hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
DataCard("Systemstatus") {
|
DataCard("Systemstatus") {
|
||||||
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
||||||
@@ -1352,7 +950,6 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
|||||||
val cards = listOf(
|
val cards = listOf(
|
||||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
||||||
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
|
|
||||||
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||||
@@ -1632,44 +1229,29 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewsletterCard(
|
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
|
||||||
newsletter: NewsletterDto,
|
|
||||||
canWrite: Boolean,
|
|
||||||
onEdit: (NewsletterDto) -> Unit = {},
|
|
||||||
onDelete: (String) -> Unit = {},
|
|
||||||
onSend: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
||||||
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
||||||
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
||||||
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
||||||
if (canWrite) {
|
Row {
|
||||||
Row {
|
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
||||||
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
if (newsletter.status != "sent") {
|
||||||
if (newsletter.status != "sent") {
|
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
||||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewsletterGroupCard(
|
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) {
|
||||||
group: NewsletterGroupDto,
|
|
||||||
canWrite: Boolean,
|
|
||||||
onEdit: (NewsletterGroupDto) -> Unit = {},
|
|
||||||
onDelete: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
DataCard(group.name.ifBlank { group.id }) {
|
DataCard(group.name.ifBlank { group.id }) {
|
||||||
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
||||||
InfoRow("Abonnenten", group.subscribers.size.toString())
|
InfoRow("Abonnenten", group.subscribers.size.toString())
|
||||||
if (canWrite) {
|
Row {
|
||||||
Row {
|
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
||||||
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||||
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
206
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Executable file → Normal file
206
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Executable file → Normal file
@@ -13,10 +13,6 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
|||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
import de.harheimertc.data.SeasonDto
|
|
||||||
import de.harheimertc.data.SpielDto
|
|
||||||
import de.harheimertc.data.TerminDto
|
|
||||||
import de.harheimertc.repositories.CmsMannschaftRow
|
|
||||||
import de.harheimertc.repositories.CmsRepository
|
import de.harheimertc.repositories.CmsRepository
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -43,16 +39,6 @@ data class CmsUiState(
|
|||||||
val passwordResetFailedOnly: Boolean = true,
|
val passwordResetFailedOnly: Boolean = true,
|
||||||
val news: List<NewsDto> = emptyList(),
|
val news: List<NewsDto> = emptyList(),
|
||||||
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||||
val sportLoading: Boolean = false,
|
|
||||||
val sportSaving: Boolean = false,
|
|
||||||
val sportTermine: List<TerminDto> = emptyList(),
|
|
||||||
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
|
|
||||||
val sportMannschaftenSeasons: List<String> = emptyList(),
|
|
||||||
val sportMannschaftenSeason: String = "",
|
|
||||||
val sportSpielplanHeaders: List<String> = emptyList(),
|
|
||||||
val sportSpielplanRows: List<List<String>> = emptyList(),
|
|
||||||
val sportSpielplanSeason: String = "",
|
|
||||||
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -193,183 +179,6 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSportbetrieb() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
|
|
||||||
|
|
||||||
val termineRes = async { repository.managedTermine() }
|
|
||||||
val seasonsRes = async { repository.mannschaftenSeasons() }
|
|
||||||
val spielplanRes = async { repository.spielplan() }
|
|
||||||
|
|
||||||
val termineResult = termineRes.await()
|
|
||||||
val seasonsResult = seasonsRes.await()
|
|
||||||
val seasonInfo = seasonsResult.getOrNull()
|
|
||||||
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
|
|
||||||
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
|
|
||||||
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
|
|
||||||
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
|
|
||||||
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
|
|
||||||
val spielplanResult = spielplanRes.await()
|
|
||||||
val errors = listOfNotNull(
|
|
||||||
ErrorMapper.mapError(termineResult.exceptionOrNull()),
|
|
||||||
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
|
|
||||||
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
|
|
||||||
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
|
|
||||||
)
|
|
||||||
val spielplan = spielplanResult.getOrNull()
|
|
||||||
val headers = spielplan?.headers.orEmpty()
|
|
||||||
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportLoading = false,
|
|
||||||
sportTermine = termineResult.getOrNull().orEmpty(),
|
|
||||||
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
|
|
||||||
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
|
|
||||||
sportMannschaftenSeason = selectedSeason,
|
|
||||||
sportSpielplanHeaders = headers,
|
|
||||||
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
|
|
||||||
sportSpielplanSeason = spielplan?.season.orEmpty(),
|
|
||||||
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
|
|
||||||
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadSportMannschaftenSeason(season: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
|
|
||||||
repository.mannschaften(season)
|
|
||||||
.onSuccess { rows ->
|
|
||||||
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
|
|
||||||
}
|
|
||||||
.onFailure { err ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportLoading = false,
|
|
||||||
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
|
||||||
if (original != null) {
|
|
||||||
repository.deleteTermin(original)
|
|
||||||
.onFailure { err ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
|
|
||||||
)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val saveResult = repository.saveTermin(termin)
|
|
||||||
saveResult
|
|
||||||
.onSuccess { response ->
|
|
||||||
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
sportTermine = termine,
|
|
||||||
message = response.message ?: "Termin gespeichert.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { err ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteSportTermin(termin: TerminDto) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
|
||||||
repository.deleteTermin(termin)
|
|
||||||
.onSuccess { response ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
sportTermine = _state.value.sportTermine.filterNot { it == termin },
|
|
||||||
message = response.message ?: "Termin gelöscht.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { err ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
|
||||||
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
|
|
||||||
.onSuccess { response ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
sportMannschaften = rows,
|
|
||||||
message = response.message ?: "Mannschaften gespeichert.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { err ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importSportSpielplan() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
|
||||||
repository.importSpielplan()
|
|
||||||
.onSuccess { response ->
|
|
||||||
if (!response.success) {
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
error = response.message ?: "Spielplan konnte nicht importiert werden.",
|
|
||||||
)
|
|
||||||
return@onSuccess
|
|
||||||
}
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
message = response.message ?: "Spielplan aktualisiert.",
|
|
||||||
)
|
|
||||||
loadSportbetrieb()
|
|
||||||
}
|
|
||||||
.onFailure { err ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
|
||||||
repository.saveSpielplan(headers, rows)
|
|
||||||
.onSuccess { response ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
sportSpielplanHeaders = headers,
|
|
||||||
sportSpielplanRows = rows,
|
|
||||||
message = response.message ?: "Spielplan gespeichert.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { err ->
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
sportSaving = false,
|
|
||||||
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveConfig(config: ConfigResponse) {
|
fun saveConfig(config: ConfigResponse) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
@@ -653,18 +462,3 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SpielDto.valueForHeader(header: String): String = when (header) {
|
|
||||||
"Termin" -> termin
|
|
||||||
"HeimMannschaft" -> heimMannschaft
|
|
||||||
"GastMannschaft" -> gastMannschaft
|
|
||||||
"HeimMannschaftAltersklasse" -> heimAltersklasse
|
|
||||||
"GastMannschaftAltersklasse" -> gastAltersklasse
|
|
||||||
"Altersklasse" -> altersklasse
|
|
||||||
"Liga" -> liga
|
|
||||||
"Staffel" -> staffel
|
|
||||||
"Runde" -> runde.orEmpty()
|
|
||||||
"SpieleHeim" -> spieleHeim
|
|
||||||
"SpieleGast" -> spieleGast
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
|
|||||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt
Executable file → Normal file
30
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt
Executable file → Normal file
30
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt
Executable file → Normal file
@@ -117,22 +117,20 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (navigationState.canAccessFullCms) {
|
item {
|
||||||
item {
|
HomeCustomizationSection(
|
||||||
HomeCustomizationSection(
|
sections = state.homepageSections,
|
||||||
sections = state.homepageSections,
|
spielplanSeasons = state.spielplanSeasons,
|
||||||
spielplanSeasons = state.spielplanSeasons,
|
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
||||||
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
editEnabled = editHomeSections,
|
||||||
editEnabled = editHomeSections,
|
onToggleEdit = { editHomeSections = !editHomeSections },
|
||||||
onToggleEdit = { editHomeSections = !editHomeSections },
|
onMoveUp = viewModel::moveSectionUp,
|
||||||
onMoveUp = viewModel::moveSectionUp,
|
onMoveDown = viewModel::moveSectionDown,
|
||||||
onMoveDown = viewModel::moveSectionDown,
|
onEnabledChange = viewModel::setSectionEnabled,
|
||||||
onEnabledChange = viewModel::setSectionEnabled,
|
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
||||||
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
||||||
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
onReset = viewModel::resetSections,
|
||||||
onReset = viewModel::resetSections,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
item {
|
item {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user