Compare commits
2 Commits
dev
...
05e25aa3d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 05e25aa3d1 | |||
| 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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: true
|
||||
# Der Analyse-Workflow benötigt keine Historie. Ein flacher Checkout
|
||||
# verhindert, dass der Runner das große Repository-Pack vollständig lädt.
|
||||
fetch-depth: 1
|
||||
# Der Web-Workflow braucht keine Android-Artefakte. Insbesondere liegt
|
||||
# dort ein großer Heap-Dump im aktuellen Commit, der auch bei einem
|
||||
# flachen Checkout übertragen würde und den Runner-Checkout abbricht.
|
||||
sparse-checkout: |
|
||||
/*
|
||||
!/android-app/
|
||||
sparse-checkout-cone-mode: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure clean workspace
|
||||
run: |
|
||||
@@ -38,7 +29,7 @@ jobs:
|
||||
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -116,18 +107,16 @@ jobs:
|
||||
- name: Semgrep (SAST)
|
||||
run: semgrep --config p/default --error .
|
||||
|
||||
- name: npm audit (critical)
|
||||
run: npm audit --audit-level=critical
|
||||
- name: npm audit (high+)
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: OSV-Scanner (SCA)
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
||||
chmod +x osv-scanner
|
||||
./osv-scanner --version
|
||||
test -f "$GITHUB_WORKSPACE/package-lock.json"
|
||||
./osv-scanner scan -L "$GITHUB_WORKSPACE/package-lock.json" --config "$GITHUB_WORKSPACE/.osv-scanner.toml" --format json --output-file osv-report.json || true
|
||||
node scripts/check-osv-severity.mjs osv-report.json
|
||||
test -f ./package-lock.json
|
||||
./osv-scanner --lockfile ./package-lock.json
|
||||
|
||||
deploy-production:
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.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/local.properties
|
||||
/android-app/gradle-local.properties
|
||||
# Android Play Store / release artifacts are generated locally or in CI.
|
||||
/android-app/**/*.aab
|
||||
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
|
||||
*.hprof
|
||||
|
||||
# Build output (but keep production data!)
|
||||
.output
|
||||
|
||||
0
.gitleaks.toml
Executable file → Normal file
0
.gitleaks.toml
Executable file → Normal file
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
4
android-app/app/build.gradle.kts
Executable file → Normal file
4
android-app/app/build.gradle.kts
Executable file → Normal file
@@ -78,12 +78,12 @@ val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 36
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.harheimertc"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
targetSdk = 35
|
||||
versionCode = androidVersionCode
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
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.
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 android.util.Log
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject
|
||||
lateinit var authRepository: AuthRepository
|
||||
|
||||
@Inject
|
||||
lateinit var pushTokenRepository: PushTokenRepository
|
||||
|
||||
private val notificationRoute = mutableStateOf<String?>(null)
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
@@ -61,23 +50,6 @@ class MainActivity : ComponentActivity() {
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
syncPushTokenWhenSignedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side tokens are persistent, but re-registering the current FCM
|
||||
* token is cheap and idempotent. It restores a token if server data was
|
||||
* ever recovered from a backup or manually repaired.
|
||||
*/
|
||||
private fun syncPushTokenWhenSignedIn() {
|
||||
if (authRepository.getToken().isNullOrBlank() && authRepository.getRefreshToken().isNullOrBlank()) return
|
||||
lifecycleScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||
|
||||
|
||||
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
34
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Executable file → Normal file
34
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Executable file → Normal file
@@ -37,22 +37,6 @@ data class SpielplanResponse(
|
||||
val season: String? = null,
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class LiveTickerResponse(
|
||||
val matches: List<LiveTickerMatchDto> = emptyList(),
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
data class LiveTickerMatchDto(
|
||||
val meetingId: String = "",
|
||||
val homeTeam: String = "",
|
||||
val awayTeam: String = "",
|
||||
val score: String = "",
|
||||
val startsAt: Long = 0,
|
||||
val hasLiveData: Boolean = false,
|
||||
val completed: Boolean = false,
|
||||
val completedAt: Long? = null,
|
||||
val updatedAt: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class MannschaftenSeasonsResponse(
|
||||
val success: Boolean = false,
|
||||
@@ -182,7 +166,6 @@ data class MembershipResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
val downloadToken: String? = null,
|
||||
)
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
@@ -285,10 +268,6 @@ data class NotificationSettingsDto(
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: List<String> = emptyList(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = "09:00",
|
||||
@@ -302,7 +281,6 @@ data class PushTokenRequest(
|
||||
val token: String,
|
||||
val platform: String = "android",
|
||||
val appVersion: String? = null,
|
||||
val installationId: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
@@ -323,7 +301,6 @@ data class QttrRowDto(
|
||||
val playerName: String = "",
|
||||
val clubName: String = "",
|
||||
val currentQttr: Int? = null,
|
||||
val currentTtr: Int? = null,
|
||||
val previousQttr: Int? = null,
|
||||
val birthdate: String? = null,
|
||||
)
|
||||
@@ -675,18 +652,12 @@ interface ApiService {
|
||||
@POST("/api/cms/save-csv")
|
||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||
|
||||
@POST("/api/cms/import-spielplan")
|
||||
suspend fun importSpielplan(): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/membership/generate-pdf")
|
||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun downloadMembershipPdf(
|
||||
@Url downloadUrl: String,
|
||||
@retrofit2.http.Header("X-Membership-Download-Token") downloadToken: String? = null,
|
||||
): Response<ResponseBody>
|
||||
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
@@ -745,9 +716,6 @@ interface ApiService {
|
||||
@GET("/api/mitgliederbereich/qttr")
|
||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||
|
||||
@GET("/api/mitgliederbereich/live-ticker")
|
||||
suspend fun liveTicker(): Response<LiveTickerResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
|
||||
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
0
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.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/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) {
|
||||
super.onNewToken(token)
|
||||
serviceScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
pushTokenRepository.registerToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt
Executable file → Normal file
10
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
|
||||
|
||||
object HarheimerNotifications {
|
||||
// A new ID is intentional: Android does not allow an app update to raise
|
||||
// the importance of an already-created notification channel.
|
||||
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates_v2"
|
||||
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
DEFAULT_CHANNEL_ID,
|
||||
"Harheimer TC",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Benachrichtigungen des Harheimer TC"
|
||||
}
|
||||
@@ -49,7 +47,7 @@ object HarheimerNotifications {
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
@@ -81,8 +79,6 @@ object HarheimerNotifications {
|
||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||
"team_matches" -> Destinations.Spielplan.route
|
||||
"live_ticker_started", "live_ticker_score_changed", "live_ticker_finished" -> Destinations.MemberArea.route
|
||||
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
|
||||
"birthdays" -> Destinations.MemberArea.route
|
||||
"contact_request" -> Destinations.CmsContactRequests.route
|
||||
"user_registration" -> Destinations.CmsBenutzer.route
|
||||
|
||||
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
10
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Executable file → Normal file
10
android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt
Executable file → Normal file
@@ -125,7 +125,6 @@ class CmsRepository @Inject constructor(
|
||||
spieler = values[7],
|
||||
informationenLink = values[8],
|
||||
letzteAktualisierung = values[9],
|
||||
spielplanMannschaftId = values.getOrElse(10) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,12 +146,6 @@ class CmsRepository @Inject constructor(
|
||||
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.")
|
||||
@@ -388,7 +381,6 @@ data class CmsMannschaftRow(
|
||||
val spieler: String = "",
|
||||
val informationenLink: String = "",
|
||||
val letzteAktualisierung: String = "",
|
||||
val spielplanMannschaftId: String = "",
|
||||
)
|
||||
|
||||
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||
@@ -403,7 +395,6 @@ private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||
"Spieler",
|
||||
"Weitere Informationen Link",
|
||||
"Letzte Aktualisierung",
|
||||
"Spielplan Mannschaft ID",
|
||||
).toCsvRow()
|
||||
val rows = map { row ->
|
||||
listOf(
|
||||
@@ -417,7 +408,6 @@ private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||
row.spieler,
|
||||
row.informationenLink,
|
||||
row.letzteAktualisierung,
|
||||
row.spielplanMannschaftId,
|
||||
).toCsvRow()
|
||||
}
|
||||
return listOf(header).plus(rows).joinToString("\n")
|
||||
|
||||
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.Singleton
|
||||
|
||||
data class MembershipDocument(val message: String, val uri: String? = null)
|
||||
data class MembershipDocument(val message: String, val uri: String)
|
||||
|
||||
@Singleton
|
||||
class MembershipRepository @Inject constructor(
|
||||
@@ -18,33 +18,21 @@ class MembershipRepository @Inject constructor(
|
||||
) {
|
||||
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
||||
val response = api.generateMembershipPdf(request)
|
||||
if (!response.isSuccessful) {
|
||||
val serverMessage = response.errorBody()?.string()
|
||||
?.let { Regex("\\\"(?:statusMessage|message)\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"").find(it)?.groupValues?.getOrNull(1) ?: it }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
error(serverMessage ?: "Der Antrag konnte nicht übermittelt werden (HTTP ${response.code()}).")
|
||||
}
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
||||
val uri = body.downloadUrl?.let { downloadUrl ->
|
||||
runCatching {
|
||||
val documentResponse = api.downloadMembershipPdf(downloadUrl, body.downloadToken)
|
||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||
val file = File(directory, "beitrittserklaerung.pdf")
|
||||
documentResponse.body()?.byteStream()?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Leere PDF-Antwort")
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.files", file).toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
|
||||
val documentResponse = api.downloadMembershipPdf(downloadUrl)
|
||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||
val file = File(directory, "beitrittserklaerung.pdf")
|
||||
documentResponse.body()?.byteStream()?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Leere PDF-Antwort")
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
MembershipDocument(
|
||||
message = if (uri == null) {
|
||||
"${body.message ?: "Mitgliedschaftsantrag erfolgreich übermittelt."} Das PDF konnte nicht auf diesem Gerät gespeichert werden."
|
||||
} else {
|
||||
body.message ?: "Beitrittsformular erfolgreich erstellt."
|
||||
},
|
||||
uri = uri,
|
||||
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
|
||||
uri = uri.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||
@@ -47,10 +43,6 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
|
||||
ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false),
|
||||
liveTickerStarts = preferences.getBoolean(KEY_LIVE_TICKER_STARTS, false),
|
||||
liveTickerScoreChanges = preferences.getBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, false),
|
||||
liveTickerFinished = preferences.getBoolean(KEY_LIVE_TICKER_FINISHED, false),
|
||||
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||
@@ -77,10 +69,6 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
||||
.putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges)
|
||||
.putBoolean(KEY_LIVE_TICKER_STARTS, settings.liveTickerStarts)
|
||||
.putBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, settings.liveTickerScoreChanges)
|
||||
.putBoolean(KEY_LIVE_TICKER_FINISHED, settings.liveTickerFinished)
|
||||
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||
@@ -88,12 +76,13 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||
saveLocal(settings)
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateNotificationSettings(settings.toDto())
|
||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||
requireLiveNotificationSettingsSaved(settings, saved)
|
||||
saveLocal(saved)
|
||||
saved
|
||||
}
|
||||
}
|
||||
@@ -109,25 +98,12 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
||||
const val KEY_OWN_TTR_CHANGES = "own_ttr_changes"
|
||||
const val KEY_LIVE_TICKER_STARTS = "live_ticker_starts"
|
||||
const val KEY_LIVE_TICKER_SCORE_CHANGES = "live_ticker_score_changes"
|
||||
const val KEY_LIVE_TICKER_FINISHED = "live_ticker_finished"
|
||||
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
|
||||
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requireLiveNotificationSettingsSaved(requested: NotificationPreferences, saved: NotificationPreferences) {
|
||||
if (requested.liveTickerStarts != saved.liveTickerStarts ||
|
||||
requested.liveTickerScoreChanges != saved.liveTickerScoreChanges ||
|
||||
requested.liveTickerFinished != saved.liveTickerFinished
|
||||
) {
|
||||
error("Der Server hat die Live-Benachrichtigungen nicht übernommen. Bitte das Backend aktualisieren und erneut versuchen.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
@@ -138,10 +114,6 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
@@ -157,10 +129,6 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
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
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
@@ -16,24 +15,19 @@ class PushTokenRepository @Inject constructor(
|
||||
) {
|
||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
val installationId = FirebaseInstallations.getInstance().id.await()
|
||||
registerToken(token, installationId).getOrThrow()
|
||||
registerToken(token).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
|
||||
retryOnNetworkFailure {
|
||||
val response = api.registerPushToken(
|
||||
PushTokenRequest(
|
||||
token = token,
|
||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||
installationId = installationId,
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
val detail = response.errorBody()?.string().orEmpty().replace(Regex("\\s+"), " ").take(180)
|
||||
error("Push-Token konnte nicht registriert werden (HTTP ${response.code()})${if (detail.isBlank()) "" else ": $detail"}")
|
||||
}
|
||||
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
|
||||
}
|
||||
}.onFailure { 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
6
android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt
Executable file → Normal file
6
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(),
|
||||
) {
|
||||
val routeSection = menuSection(selectedRoute)
|
||||
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = sectionOverride.value ?: routeSection
|
||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = routeSection ?: sectionOverride.value
|
||||
val subItems = submenu(section, navigationState)
|
||||
|
||||
val mainScroll = rememberScrollState()
|
||||
@@ -472,7 +472,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.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("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.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
0
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt
Executable file → Normal file
141
android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt
Executable file → Normal file
141
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.LaunchedEffect
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -12,8 +10,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -26,7 +22,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.LiveTickerBanner
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
@@ -46,13 +41,8 @@ fun NavGraph(
|
||||
LaunchedEffect(currentRoute) {
|
||||
navigationViewModel.refreshSession()
|
||||
}
|
||||
LaunchedEffect(navigationState.loggedIn) {
|
||||
navigationViewModel.setLiveTickerActive(navigationState.loggedIn)
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
val tabletNavigation = maxWidth >= 600.dp
|
||||
// Signed-in phones share the same menu and ticker above every screen.
|
||||
val persistentNavigation = tabletNavigation || navigationState.loggedIn
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val persistentNavigation = maxWidth >= 600.dp
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
navigationState.connectionNote?.let { message ->
|
||||
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -75,16 +65,10 @@ fun NavGraph(
|
||||
}
|
||||
}
|
||||
},
|
||||
webTabletNavigation = tabletNavigation,
|
||||
webTabletNavigation = true,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
LiveTickerBanner(
|
||||
matches = navigationState.liveTickerMatches,
|
||||
loaded = navigationState.liveTickerLoaded,
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
@@ -343,141 +327,46 @@ fun NavGraph(
|
||||
)
|
||||
}
|
||||
composable(Destinations.CmsStartseite.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsInhalte.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsNews.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsSportbetrieb.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsMitgliederverwaltung.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsNewsletter.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessNewsletter,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsContactRequests.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessContactRequests,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsEinstellungen.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsBenutzer.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsPasswordResetDiagnostics.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessFullCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Cms.route) {
|
||||
RequireCmsAccess(
|
||||
allowed = navigationState.canAccessCms,
|
||||
navController = navController,
|
||||
) {
|
||||
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
||||
}
|
||||
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequireCmsAccess(
|
||||
allowed: Boolean,
|
||||
navController: NavHostController,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (allowed) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Kein Zugriff auf diesen Bereich.",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = Color(0xFF7F1D1D),
|
||||
)
|
||||
Text(
|
||||
text = "Bitte mit einem berechtigten Konto anmelden.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF7C2D12),
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||
)
|
||||
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
|
||||
Text("Zum Login")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavHostController.navigateTopLevel(route: String) {
|
||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||
route != Destinations.Spielsysteme.route
|
||||
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.LiveTickerRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -27,8 +22,6 @@ data class NavigationUiState(
|
||||
val loggedIn: Boolean = false,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val connectionNote: String? = null,
|
||||
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
|
||||
val liveTickerLoaded: Boolean = false,
|
||||
) {
|
||||
val isAdmin: Boolean get() = "admin" in roles
|
||||
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||
@@ -46,12 +39,9 @@ class NavigationViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
private val liveTickerRepository: LiveTickerRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
private var tickerJob: Job? = null
|
||||
private var tickerActive = false
|
||||
|
||||
init {
|
||||
loadNavigationData()
|
||||
@@ -80,8 +70,6 @@ class NavigationViewModel @Inject constructor(
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = null,
|
||||
liveTickerMatches = _state.value.liveTickerMatches,
|
||||
liveTickerLoaded = _state.value.liveTickerLoaded,
|
||||
)
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
@@ -126,7 +96,6 @@ class NavigationViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun logout(onComplete: () -> Unit = {}) {
|
||||
setLiveTickerActive(false)
|
||||
viewModelScope.launch {
|
||||
loginRepository.logout()
|
||||
_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 ->
|
||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||
NewsListItem(
|
||||
news = news,
|
||||
canWrite = canWrite,
|
||||
selected = selected,
|
||||
onSelect = { id, sel ->
|
||||
id?.let {
|
||||
selection = if (sel) selection + it else selection - it
|
||||
}
|
||||
},
|
||||
onEdit = { openEdit(news) },
|
||||
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
|
||||
)
|
||||
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
||||
id?.let {
|
||||
selection = if (sel) selection + it else selection - it
|
||||
}
|
||||
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
||||
}
|
||||
|
||||
// bulk action bar
|
||||
if (canWrite && selection.isNotEmpty()) {
|
||||
if (selection.isNotEmpty()) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||
@@ -262,7 +255,6 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
||||
@Composable
|
||||
private fun NewsListItem(
|
||||
news: NewsDto,
|
||||
canWrite: Boolean,
|
||||
selected: Boolean = false,
|
||||
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||
onEdit: (NewsDto) -> Unit,
|
||||
@@ -272,11 +264,7 @@ private fun NewsListItem(
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onSelect(news.id, it) },
|
||||
enabled = canWrite,
|
||||
)
|
||||
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||
if (news.isPublic) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
@@ -309,11 +297,9 @@ private fun NewsListItem(
|
||||
Text("Aktualisiert: ${news.updated}")
|
||||
}
|
||||
}
|
||||
if (canWrite) {
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
107
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Executable file → Normal file
107
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt
Executable file → Normal file
@@ -458,9 +458,6 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
||||
"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) {
|
||||
@@ -609,7 +606,6 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
||||
items(mannschaften.size) { index ->
|
||||
MannschaftEditorCard(
|
||||
row = mannschaften[index],
|
||||
spielplanTeams = spielplanTeamOptions,
|
||||
onChange = { updated -> mannschaften[index] = updated },
|
||||
onRemove = { mannschaften.removeAt(index) },
|
||||
)
|
||||
@@ -623,19 +619,9 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
||||
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")
|
||||
@@ -746,32 +732,11 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
||||
@Composable
|
||||
private fun MannschaftEditorCard(
|
||||
row: CmsMannschaftRow,
|
||||
spielplanTeams: List<ImportedSpielplanTeam>,
|
||||
onChange: (CmsMannschaftRow) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
|
||||
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
||||
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
|
||||
if (spielplanTeams.isNotEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Aus importierten Teams auswählen")
|
||||
}
|
||||
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
|
||||
spielplanTeams.forEach { team ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(team.label) },
|
||||
onClick = {
|
||||
onChange(row.copy(spielplanMannschaftId = team.id))
|
||||
teamPickerOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
||||
@@ -787,35 +752,6 @@ private fun MannschaftEditorCard(
|
||||
}
|
||||
}
|
||||
|
||||
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(";") } }
|
||||
@@ -936,9 +872,7 @@ fun CmsNewsletterScreen(
|
||||
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
||||
items(state.newsletters.size) { index ->
|
||||
val item = state.newsletters[index]
|
||||
NewsletterCard(
|
||||
item,
|
||||
canWrite = canWrite,
|
||||
NewsletterCard(item,
|
||||
onEdit = { nl ->
|
||||
editingNewsletter = nl
|
||||
nlTitle = nl.title
|
||||
@@ -956,9 +890,7 @@ fun CmsNewsletterScreen(
|
||||
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
||||
items(state.newsletterGroups.size) { index ->
|
||||
val group = state.newsletterGroups[index]
|
||||
NewsletterGroupCard(
|
||||
group,
|
||||
canWrite = canWrite,
|
||||
NewsletterGroupCard(group,
|
||||
onEdit = { g ->
|
||||
editingGroup = g
|
||||
grpName = g.name
|
||||
@@ -1632,44 +1564,29 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterCard(
|
||||
newsletter: NewsletterDto,
|
||||
canWrite: Boolean,
|
||||
onEdit: (NewsletterDto) -> Unit = {},
|
||||
onDelete: (String) -> Unit = {},
|
||||
onSend: (String) -> Unit = {},
|
||||
) {
|
||||
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
|
||||
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
||||
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
||||
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
||||
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
||||
if (canWrite) {
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
if (newsletter.status != "sent") {
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
if (newsletter.status != "sent") {
|
||||
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterGroupCard(
|
||||
group: NewsletterGroupDto,
|
||||
canWrite: Boolean,
|
||||
onEdit: (NewsletterGroupDto) -> Unit = {},
|
||||
onDelete: (String) -> Unit = {},
|
||||
) {
|
||||
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) {
|
||||
DataCard(group.name.ifBlank { group.id }) {
|
||||
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
||||
InfoRow("Abonnenten", group.subscribers.size.toString())
|
||||
if (canWrite) {
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Executable file → Normal file
27
android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt
Executable file → Normal file
@@ -322,33 +322,6 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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 {
|
||||
HomeCustomizationSection(
|
||||
sections = state.homepageSections,
|
||||
spielplanSeasons = state.spielplanSeasons,
|
||||
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
||||
editEnabled = editHomeSections,
|
||||
onToggleEdit = { editHomeSections = !editHomeSections },
|
||||
onMoveUp = viewModel::moveSectionUp,
|
||||
onMoveDown = viewModel::moveSectionDown,
|
||||
onEnabledChange = viewModel::setSectionEnabled,
|
||||
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
||||
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
||||
onReset = viewModel::resetSections,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeCustomizationSection(
|
||||
sections = state.homepageSections,
|
||||
spielplanSeasons = state.spielplanSeasons,
|
||||
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
||||
editEnabled = editHomeSections,
|
||||
onToggleEdit = { editHomeSections = !editHomeSections },
|
||||
onMoveUp = viewModel::moveSectionUp,
|
||||
onMoveDown = viewModel::moveSectionDown,
|
||||
onEnabledChange = viewModel::setSectionEnabled,
|
||||
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
||||
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
||||
onReset = viewModel::resetSections,
|
||||
)
|
||||
}
|
||||
if (state.error) {
|
||||
item {
|
||||
|
||||
0
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt
Executable file → Normal file
0
android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt
Executable file → Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user