1 Commits

Author SHA1 Message Date
ecae88af78 Merge pull request 'dev' (#41) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #41
2026-06-10 16:49:07 +02:00
614 changed files with 8488 additions and 10081 deletions

0
.codex Executable file → Normal file
View File

25
.gitea/workflows/code-analysis.yml Executable file → Normal file
View 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
View File

6
.github/workflows/android-ci.yml vendored Executable file → Normal file
View 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
View 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

View File

@@ -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
View File

2
.nvmrc Executable file → Normal file
View File

@@ -1 +1 @@
24.22.1 22

View 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
View File

0
ANDROID_ARCHITECTURE.md Executable file → Normal file
View File

0
ANDROID_KOTLIN_PLAN.md Executable file → Normal file
View File

0
ANDROID_PORT_TODO.md Executable file → Normal file
View File

0
ANDROID_REPO_ENDPOINTS.md Executable file → Normal file
View File

0
AUTH_README.md Executable file → Normal file
View File

0
DATENSCHUTZ.md Executable file → Normal file
View File

0
DATENSCHUTZ_UEBERSICHT.md Executable file → Normal file
View File

0
DEPLOYMENT.md Executable file → Normal file
View File

View 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
View File

11
android-app/app/build.gradle.kts Executable file → Normal file
View 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) {

Binary file not shown.

0
android-app/app/proguard-rules.pro vendored Executable file → Normal file
View File

0
android-app/app/src/androidTest/AndroidManifest.xml Executable file → Normal file
View File

View File

0
android-app/app/src/debug/AndroidManifest.xml Executable file → Normal file
View File

0
android-app/app/src/main/AndroidManifest.xml Executable file → Normal file
View File

View File

View 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() }

View 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>>)

View File

View 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())
@@ -46,4 +46,4 @@ class ConnectivityMonitor @Inject constructor(
val capabilities = manager.getNetworkCapabilities(network) ?: return false val capabilities = manager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} }
} }

View File

View File

View File

View File

View 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)
} }
} }

View 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
} }
} }

View 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()

View 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()
}
}
}

View 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,
) )
} }
} }

View 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,

View 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)

View 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))

View 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,
)
}
}
}

View File

@@ -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)
}
}
}

View File

View 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")

View 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

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

View 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") }
}
} }
} }
} }

View 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") }
}
} }
} }
} }

View 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 -> ""
}

View 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