Merge pull request 'dev' (#41) from dev into main
Reviewed-on: #41
@@ -146,7 +146,7 @@ jobs:
|
|||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-p "${{ vars.PROD_PORT }}" \
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
"bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||||
|
|
||||||
deploy-test:
|
deploy-test:
|
||||||
needs: analyze
|
needs: analyze
|
||||||
@@ -177,4 +177,4 @@ jobs:
|
|||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-p "${{ vars.PROD_PORT }}" \
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
"bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||||
|
|||||||
3
.gitleaksignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
google-services.json:gcp-api-key:18
|
||||||
|
google-services.json:gcp-api-key:37
|
||||||
|
google-services.json:gcp-api-key:56
|
||||||
@@ -7,12 +7,17 @@ plugins {
|
|||||||
id("com.google.dagger.hilt.android")
|
id("com.google.dagger.hilt.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file("google-services.json").exists()) {
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
|
}
|
||||||
|
|
||||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||||
.orElse("https://harheimertc.tsschulz.de/")
|
.orElse("https://harheimertc.tsschulz.de/")
|
||||||
.get()
|
.get()
|
||||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||||
.orElse("https://harheimertc.de/")
|
.orElse("https://harheimertc.de/")
|
||||||
.get()
|
.get()
|
||||||
|
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
|
||||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||||
.orElse("")
|
.orElse("")
|
||||||
.get()
|
.get()
|
||||||
@@ -61,6 +66,16 @@ val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||||
|
doFirst {
|
||||||
|
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
|
||||||
|
throw GradleException(
|
||||||
|
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.harheimertc"
|
namespace = "de.harheimertc"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -163,6 +178,7 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
|||||||
group = "distribution"
|
group = "distribution"
|
||||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||||
dependsOn(ensureReleaseSigning)
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
dependsOn(":app:bundleProductionRelease")
|
dependsOn(":app:bundleProductionRelease")
|
||||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||||
|
|
||||||
@@ -193,6 +209,7 @@ tasks.matching {
|
|||||||
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||||
}.configureEach {
|
}.configureEach {
|
||||||
dependsOn(ensureReleaseSigning)
|
dependsOn(ensureReleaseSigning)
|
||||||
|
dependsOn(ensureProductionApiBaseUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -240,6 +257,9 @@ dependencies {
|
|||||||
// Crash reporting
|
// Crash reporting
|
||||||
implementation("io.sentry:sentry-android:8.42.0")
|
implementation("io.sentry:sentry-android:8.42.0")
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
implementation("com.google.firebase:firebase-messaging:25.0.2")
|
||||||
|
|
||||||
// Room
|
// Room
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
@@ -249,6 +269,7 @@ dependencies {
|
|||||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||||
|
|
||||||
// Testing (skeleton)
|
// Testing (skeleton)
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|||||||
1
android-app/app/google-services.json
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../google-services.json
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HarheimerApplication"
|
android:name=".HarheimerApplication"
|
||||||
@@ -10,12 +11,20 @@
|
|||||||
android:theme="@style/Theme.HarheimerTC"
|
android:theme="@style/Theme.HarheimerTC"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
<activity android:name="de.harheimertc.MainActivity"
|
<activity android:name="de.harheimertc.MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".notifications.HarheimerMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.files"
|
android:authorities="${applicationId}.files"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
|
|||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
import io.sentry.Sentry
|
import io.sentry.Sentry
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
HarheimerNotifications.createChannels(this)
|
||||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||||
Sentry.init { options ->
|
Sentry.init { options ->
|
||||||
options.dsn = BuildConfig.SENTRY_DSN
|
options.dsn = BuildConfig.SENTRY_DSN
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
package de.harheimertc
|
package de.harheimertc
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import de.harheimertc.ui.navigation.NavGraph
|
import de.harheimertc.ui.navigation.NavGraph
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
import de.harheimertc.ui.theme.HarheimerTheme
|
import de.harheimertc.ui.theme.HarheimerTheme
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
@@ -17,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val notificationRoute = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
notificationRoute.value = extractNotificationRoute(intent)
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(
|
||||||
|
notificationRoute = notificationRoute.value,
|
||||||
|
onNotificationRouteConsumed = { notificationRoute.value = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
notificationRoute.value = extractNotificationRoute(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||||
|
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
|
||||||
|
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
|
||||||
|
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(
|
||||||
|
notificationRoute: String? = null,
|
||||||
|
onNotificationRouteConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
HarheimerTheme {
|
HarheimerTheme {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
val activity = ctx as? ComponentActivity
|
val activity = ctx as? ComponentActivity
|
||||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||||
|
LaunchedEffect(notificationRoute) {
|
||||||
|
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
|
||||||
|
navController.navigate(route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
popUpTo(Destinations.Home.route)
|
||||||
|
}
|
||||||
|
onNotificationRouteConsumed()
|
||||||
|
}
|
||||||
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ data class SpielplanResponse(
|
|||||||
val seasons: List<SeasonDto> = emptyList(),
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
)
|
)
|
||||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||||
|
data class MannschaftenSeasonsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val seasons: List<String> = emptyList(),
|
||||||
|
val currentSeason: String = "",
|
||||||
|
val defaultSeason: String = "",
|
||||||
|
)
|
||||||
data class SpielDto(
|
data class SpielDto(
|
||||||
@param:Json(name = "Termin") val termin: String = "",
|
@param:Json(name = "Termin") val termin: String = "",
|
||||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||||
@@ -251,6 +257,30 @@ data class ProfileUpdateRequest(
|
|||||||
val currentPassword: String? = null,
|
val currentPassword: String? = null,
|
||||||
val newPassword: String? = null,
|
val newPassword: String? = null,
|
||||||
)
|
)
|
||||||
|
data class NotificationSettingsDto(
|
||||||
|
val newNews: Boolean = false,
|
||||||
|
val newEvents: Boolean = false,
|
||||||
|
val eventsToday: Boolean = false,
|
||||||
|
val eventsTomorrow: Boolean = false,
|
||||||
|
val ownTeamMatches: Boolean = false,
|
||||||
|
val allTeamMatches: Boolean = false,
|
||||||
|
val birthdays: Boolean = false,
|
||||||
|
val newContactRequest: Boolean = false,
|
||||||
|
val newUserRegistration: Boolean = false,
|
||||||
|
val selectedTeamSlugs: List<String> = emptyList(),
|
||||||
|
val selectedTeamSeason: String? = null,
|
||||||
|
val notificationTime: String = "09:00",
|
||||||
|
)
|
||||||
|
data class NotificationSettingsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val settings: NotificationSettingsDto = NotificationSettingsDto(),
|
||||||
|
)
|
||||||
|
data class PushTokenRequest(
|
||||||
|
val token: String,
|
||||||
|
val platform: String = "android",
|
||||||
|
val appVersion: String? = null,
|
||||||
|
)
|
||||||
data class BirthdayDto(
|
data class BirthdayDto(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val dayMonth: String = "",
|
val dayMonth: String = "",
|
||||||
@@ -584,6 +614,9 @@ interface ApiService {
|
|||||||
@GET("/api/mannschaften")
|
@GET("/api/mannschaften")
|
||||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("/api/mannschaften/seasons")
|
||||||
|
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||||
|
|
||||||
@GET("/api/config")
|
@GET("/api/config")
|
||||||
suspend fun config(): Response<ConfigResponse>
|
suspend fun config(): Response<ConfigResponse>
|
||||||
|
|
||||||
@@ -651,6 +684,15 @@ interface ApiService {
|
|||||||
@retrofit2.http.PUT("/api/profile")
|
@retrofit2.http.PUT("/api/profile")
|
||||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||||
|
|
||||||
|
@GET("/api/profile/notifications")
|
||||||
|
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
|
||||||
|
|
||||||
|
@retrofit2.http.PUT("/api/profile/notifications")
|
||||||
|
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
|
||||||
|
|
||||||
|
@POST("/api/profile/push-token")
|
||||||
|
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/birthdays")
|
@GET("/api/birthdays")
|
||||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityMonitor @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val _online = MutableStateFlow(hasInternetAccess())
|
||||||
|
val online: StateFlow<Boolean> = _online.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { poll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun poll() {
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val current = hasInternetAccess()
|
||||||
|
if (_online.value != current) {
|
||||||
|
_online.value = current
|
||||||
|
}
|
||||||
|
delay(10_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasInternetAccess(): Boolean {
|
||||||
|
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return false
|
||||||
|
val network = manager.activeNetwork ?: return false
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package de.harheimertc.data
|
package de.harheimertc.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val moshi: Moshi,
|
private val moshi: Moshi,
|
||||||
) {
|
) {
|
||||||
|
private val tag = "SecureOfflineCache"
|
||||||
private companion object {
|
private companion object {
|
||||||
const val KEY_BIRTHDAYS = "birthdays"
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
const val KEY_QTTR_VALUES = "qttr_values"
|
const val KEY_QTTR_VALUES = "qttr_values"
|
||||||
@@ -29,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -39,6 +46,28 @@ class SecureOfflineCache @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_offline_cache")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_offline_cache",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.harheimertc.notifications
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class HarheimerMessagingService : FirebaseMessagingService() {
|
||||||
|
@Inject
|
||||||
|
lateinit var pushTokenRepository: PushTokenRepository
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
serviceScope.launch {
|
||||||
|
pushTokenRepository.registerToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
val title = message.notification?.title
|
||||||
|
?: message.data["title"]
|
||||||
|
?: "Harheimer TC"
|
||||||
|
val body = message.notification?.body
|
||||||
|
?: message.data["body"]
|
||||||
|
?: message.data["message"]
|
||||||
|
?: return
|
||||||
|
val notificationId = message.data["notificationId"]?.toIntOrNull()
|
||||||
|
?: message.messageId?.hashCode()
|
||||||
|
?: System.currentTimeMillis().toInt()
|
||||||
|
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body, message.data)
|
||||||
|
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.harheimertc.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.harheimertc.MainActivity
|
||||||
|
import de.harheimertc.R
|
||||||
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
|
||||||
|
object HarheimerNotifications {
|
||||||
|
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
|
||||||
|
|
||||||
|
fun createChannels(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
DEFAULT_CHANNEL_ID,
|
||||||
|
"Harheimer TC",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = "Benachrichtigungen des Harheimer TC"
|
||||||
|
}
|
||||||
|
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNotificationPermission(context: Context): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
fun showBasicNotification(
|
||||||
|
context: Context,
|
||||||
|
notificationId: Int,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
data: Map<String, String> = emptyMap(),
|
||||||
|
): Boolean {
|
||||||
|
if (!hasNotificationPermission(context)) return false
|
||||||
|
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(message)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||||
|
val route = destinationRoute(payload)
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
|
||||||
|
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
|
||||||
|
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
notificationId,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||||
|
"news" -> Destinations.MemberNews.route
|
||||||
|
else -> Destinations.Home.route
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.harheimertc.security.DeviceKeyManager
|
import de.harheimertc.security.DeviceKeyManager
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val deviceKeyManager: DeviceKeyManager,
|
private val deviceKeyManager: DeviceKeyManager,
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
private val tag = "AuthRepository"
|
||||||
private val tokenKey = "auth_token"
|
private val tokenKey = "auth_token"
|
||||||
private val refreshTokenKey = "auth_refresh_token"
|
private val refreshTokenKey = "auth_refresh_token"
|
||||||
private val sessionIdKey = "auth_session_id"
|
private val sessionIdKey = "auth_session_id"
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_auth")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_auth",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||||
|
|||||||
@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||||
val response = api.galerieList(page = 1, perPage = 1)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.galerieList(page = 1, perPage = 1)
|
||||||
response.body()?.images.orEmpty().isNotEmpty()
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.images.orEmpty().isNotEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||||
return try {
|
return runCatching {
|
||||||
val resp = api.galerieList(page = page, perPage = perPage)
|
retryOnNetworkFailure {
|
||||||
if (resp.isSuccessful) {
|
val resp = api.galerieList(page = page, perPage = perPage)
|
||||||
val body = resp.body()
|
if (resp.isSuccessful) {
|
||||||
Result.success(
|
val body = resp.body()
|
||||||
GalleryPage(
|
GalleryPage(
|
||||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||||
),
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
error("HTTP ${resp.code()}")
|
||||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.harheimertc.data.HomepageSectionDto
|
import de.harheimertc.data.HomepageSectionDto
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
|
|||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val moshi: Moshi,
|
private val moshi: Moshi,
|
||||||
) {
|
) {
|
||||||
|
private val tag = "HomeLayoutPreferences"
|
||||||
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||||
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||||
|
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
|
buildEncryptedPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEncryptedPreferences() = try {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
|
|||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
|
} catch (error: GeneralSecurityException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
recoverEncryptedPreferences(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||||
|
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
|
||||||
|
context.deleteSharedPreferences("harheimertc_home_layout")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_home_layout",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (retryError: Throwable) {
|
||||||
|
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
|
||||||
|
throw retryError
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSections(): List<HomepageSectionDto>? {
|
fun getSections(): List<HomepageSectionDto>? {
|
||||||
|
|||||||
@@ -31,19 +31,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
val diagnostics = mutableListOf<String>()
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
val termine = runCatching {
|
val termine = runCatching {
|
||||||
val response = api.termine()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) {
|
val response = api.termine()
|
||||||
val errorBody = response.errorBody()?.string().orEmpty()
|
if (!response.isSuccessful) {
|
||||||
diagnostics += buildDiagnostic(
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
endpoint = "GET /api/termine",
|
diagnostics += buildDiagnostic(
|
||||||
requestPayload = "none",
|
endpoint = "GET /api/termine",
|
||||||
httpCode = response.code(),
|
requestPayload = "none",
|
||||||
responseBody = errorBody,
|
httpCode = response.code(),
|
||||||
throwable = null,
|
responseBody = errorBody,
|
||||||
)
|
throwable = null,
|
||||||
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
)
|
||||||
|
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
}
|
}
|
||||||
response.body()?.termine.orEmpty()
|
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
captureLoadIssue("fetchHomeData.termine", error)
|
captureLoadIssue("fetchHomeData.termine", error)
|
||||||
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||||
@@ -58,19 +60,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
val spielplanResponse = runCatching {
|
val spielplanResponse = runCatching {
|
||||||
val response = api.spielplan()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) {
|
val response = api.spielplan()
|
||||||
val errorBody = response.errorBody()?.string().orEmpty()
|
if (!response.isSuccessful) {
|
||||||
diagnostics += buildDiagnostic(
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
endpoint = "GET /api/spielplan",
|
diagnostics += buildDiagnostic(
|
||||||
requestPayload = "none",
|
endpoint = "GET /api/spielplan",
|
||||||
httpCode = response.code(),
|
requestPayload = "none",
|
||||||
responseBody = errorBody,
|
httpCode = response.code(),
|
||||||
throwable = null,
|
responseBody = errorBody,
|
||||||
)
|
throwable = null,
|
||||||
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
)
|
||||||
|
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()
|
||||||
}
|
}
|
||||||
response.body()
|
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
captureLoadIssue("fetchHomeData.spielplan", error)
|
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||||
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||||
@@ -86,19 +90,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
val spiele = spielplanResponse?.data.orEmpty()
|
val spiele = spielplanResponse?.data.orEmpty()
|
||||||
|
|
||||||
val news = runCatching {
|
val news = runCatching {
|
||||||
val response = api.publicNews()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) {
|
val response = api.publicNews()
|
||||||
val errorBody = response.errorBody()?.string().orEmpty()
|
if (!response.isSuccessful) {
|
||||||
diagnostics += buildDiagnostic(
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
endpoint = "GET /api/news-public",
|
diagnostics += buildDiagnostic(
|
||||||
requestPayload = "none",
|
endpoint = "GET /api/news-public",
|
||||||
httpCode = response.code(),
|
requestPayload = "none",
|
||||||
responseBody = errorBody,
|
httpCode = response.code(),
|
||||||
throwable = null,
|
responseBody = errorBody,
|
||||||
)
|
throwable = null,
|
||||||
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
)
|
||||||
|
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.news.orEmpty()
|
||||||
}
|
}
|
||||||
response.body()?.news.orEmpty()
|
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
captureLoadIssue("fetchHomeData.news", error)
|
captureLoadIssue("fetchHomeData.news", error)
|
||||||
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||||
@@ -113,19 +119,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
val homepageSections = runCatching {
|
val homepageSections = runCatching {
|
||||||
val response = api.config()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) {
|
val response = api.config()
|
||||||
val errorBody = response.errorBody()?.string().orEmpty()
|
if (!response.isSuccessful) {
|
||||||
diagnostics += buildDiagnostic(
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
endpoint = "GET /api/config",
|
diagnostics += buildDiagnostic(
|
||||||
requestPayload = "none",
|
endpoint = "GET /api/config",
|
||||||
httpCode = response.code(),
|
requestPayload = "none",
|
||||||
responseBody = errorBody,
|
httpCode = response.code(),
|
||||||
throwable = null,
|
responseBody = errorBody,
|
||||||
)
|
throwable = null,
|
||||||
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
)
|
||||||
|
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
response.body()?.homepage?.sections.orEmpty()
|
||||||
}
|
}
|
||||||
response.body()?.homepage?.sections.orEmpty()
|
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
captureLoadIssue("fetchHomeData.config", error)
|
captureLoadIssue("fetchHomeData.config", error)
|
||||||
if (diagnostics.none { it.contains("GET /api/config") }) {
|
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||||
@@ -140,20 +148,22 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
val heroImageUrl = runCatching {
|
val heroImageUrl = runCatching {
|
||||||
val response = api.heroImages()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) {
|
val response = api.heroImages()
|
||||||
val errorBody = response.errorBody()?.string().orEmpty()
|
if (!response.isSuccessful) {
|
||||||
diagnostics += buildDiagnostic(
|
val errorBody = response.errorBody()?.string().orEmpty()
|
||||||
endpoint = "GET /api/hero-images",
|
diagnostics += buildDiagnostic(
|
||||||
requestPayload = "none",
|
endpoint = "GET /api/hero-images",
|
||||||
httpCode = response.code(),
|
requestPayload = "none",
|
||||||
responseBody = errorBody,
|
httpCode = response.code(),
|
||||||
throwable = null,
|
responseBody = errorBody,
|
||||||
)
|
throwable = null,
|
||||||
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
)
|
||||||
|
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||||
|
}
|
||||||
|
val variants = response.body()?.variants.orEmpty()
|
||||||
|
pickRandomHeroImage(variants)
|
||||||
}
|
}
|
||||||
val variants = response.body()?.variants.orEmpty()
|
|
||||||
pickRandomHeroImage(variants)
|
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
captureLoadIssue("fetchHomeData.heroImages", error)
|
captureLoadIssue("fetchHomeData.heroImages", error)
|
||||||
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
||||||
@@ -187,9 +197,11 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||||
val response = api.spielplan(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplan(season)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
Sentry.withScope { scope ->
|
Sentry.withScope { scope ->
|
||||||
scope.setTag("repository", "HomeRepository")
|
scope.setTag("repository", "HomeRepository")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class LoginRepository @Inject constructor(
|
|||||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||||
val endpoint = "api/auth/login"
|
val endpoint = "api/auth/login"
|
||||||
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||||
val response = api.login(LoginRequest(email.trim(), password))
|
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
val body = response.errorBody()?.string().orEmpty()
|
val body = response.errorBody()?.string().orEmpty()
|
||||||
val serverMessage = extractServerMessage(body)
|
val serverMessage = extractServerMessage(body)
|
||||||
@@ -71,11 +71,11 @@ class LoginRepository @Inject constructor(
|
|||||||
return@runCatching AuthStatusResponse()
|
return@runCatching AuthStatusResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = api.authStatus()
|
var response = retryOnNetworkFailure { api.authStatus() }
|
||||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
var status = response.body() ?: AuthStatusResponse()
|
var status = response.body() ?: AuthStatusResponse()
|
||||||
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
|
||||||
response = api.authStatus()
|
response = retryOnNetworkFailure { api.authStatus() }
|
||||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||||
status = response.body() ?: AuthStatusResponse()
|
status = response.body() ?: AuthStatusResponse()
|
||||||
}
|
}
|
||||||
@@ -93,15 +93,19 @@ class LoginRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.register(request)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
val response = api.register(request)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractServerMessage(raw: String): String? {
|
private fun extractServerMessage(raw: String): String? {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -24,9 +26,19 @@ data class Mannschaft(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||||
val response = api.mannschaften(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
val response = api.mannschaften(season)
|
||||||
parseCsv(response.body()?.string().orEmpty())
|
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||||
|
parseCsv(response.body()?.string().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
internal suspend fun <T> retryOnNetworkFailure(
|
||||||
|
retryDelayMillis: Long = 10_000L,
|
||||||
|
block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
if (!error.isRetryableNetworkError()) throw error
|
||||||
|
delay(retryDelayMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
|
||||||
|
is UnknownHostException,
|
||||||
|
is ConnectException,
|
||||||
|
is NoRouteToHostException,
|
||||||
|
is SocketTimeoutException,
|
||||||
|
is SSLException -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||||
val response = api.publicNewsletterGroups()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
val response = api.publicNewsletterGroups()
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
||||||
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||||
val response = api.confirmNewsletter(token)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
val response = api.confirmNewsletter(token)
|
||||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||||
|
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.NotificationSettingsDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private const val DEFAULT_NOTIFICATION_TIME = "09:00"
|
||||||
|
|
||||||
|
data class NotificationPreferences(
|
||||||
|
val newNews: Boolean = false,
|
||||||
|
val newEvents: Boolean = false,
|
||||||
|
val eventsToday: Boolean = false,
|
||||||
|
val eventsTomorrow: Boolean = false,
|
||||||
|
val ownTeamMatches: Boolean = false,
|
||||||
|
val allTeamMatches: Boolean = false,
|
||||||
|
val birthdays: Boolean = false,
|
||||||
|
val newContactRequest: Boolean = false,
|
||||||
|
val newUserRegistration: Boolean = false,
|
||||||
|
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||||
|
val selectedTeamSeason: String? = null,
|
||||||
|
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NotificationPreferencesRepository @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val api: ApiService,
|
||||||
|
) {
|
||||||
|
private val preferences by lazy {
|
||||||
|
context.getSharedPreferences("harheimertc_notification_preferences", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadLocal(): NotificationPreferences = NotificationPreferences(
|
||||||
|
newNews = preferences.getBoolean(KEY_NEW_NEWS, false),
|
||||||
|
newEvents = preferences.getBoolean(KEY_NEW_EVENTS, false),
|
||||||
|
eventsToday = preferences.getBoolean(KEY_EVENTS_TODAY, false),
|
||||||
|
eventsTomorrow = preferences.getBoolean(KEY_EVENTS_TOMORROW, false),
|
||||||
|
ownTeamMatches = preferences.getBoolean(KEY_OWN_TEAM_MATCHES, false),
|
||||||
|
allTeamMatches = preferences.getBoolean(KEY_ALL_TEAM_MATCHES, false),
|
||||||
|
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||||
|
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||||
|
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
|
||||||
|
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||||
|
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||||
|
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadRemote(): Result<NotificationPreferences> = runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.notificationSettings()
|
||||||
|
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht geladen werden.")
|
||||||
|
val settings = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||||
|
saveLocal(settings)
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLocal(settings: NotificationPreferences) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(KEY_NEW_NEWS, settings.newNews)
|
||||||
|
.putBoolean(KEY_NEW_EVENTS, settings.newEvents)
|
||||||
|
.putBoolean(KEY_EVENTS_TODAY, settings.eventsToday)
|
||||||
|
.putBoolean(KEY_EVENTS_TOMORROW, settings.eventsTomorrow)
|
||||||
|
.putBoolean(KEY_OWN_TEAM_MATCHES, settings.ownTeamMatches)
|
||||||
|
.putBoolean(KEY_ALL_TEAM_MATCHES, settings.allTeamMatches)
|
||||||
|
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||||
|
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||||
|
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
||||||
|
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||||
|
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||||
|
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||||
|
saveLocal(settings)
|
||||||
|
return runCatching {
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.updateNotificationSettings(settings.toDto())
|
||||||
|
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
|
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||||
|
saveLocal(saved)
|
||||||
|
saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY_NEW_NEWS = "new_news"
|
||||||
|
const val KEY_NEW_EVENTS = "new_events"
|
||||||
|
const val KEY_EVENTS_TODAY = "events_today"
|
||||||
|
const val KEY_EVENTS_TOMORROW = "events_tomorrow"
|
||||||
|
const val KEY_OWN_TEAM_MATCHES = "own_team_matches"
|
||||||
|
const val KEY_ALL_TEAM_MATCHES = "all_team_matches"
|
||||||
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
|
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||||
|
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
||||||
|
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
|
||||||
|
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||||
|
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||||
|
newNews = newNews,
|
||||||
|
newEvents = newEvents,
|
||||||
|
eventsToday = eventsToday,
|
||||||
|
eventsTomorrow = eventsTomorrow,
|
||||||
|
ownTeamMatches = ownTeamMatches,
|
||||||
|
allTeamMatches = allTeamMatches,
|
||||||
|
birthdays = birthdays,
|
||||||
|
newContactRequest = newContactRequest,
|
||||||
|
newUserRegistration = newUserRegistration,
|
||||||
|
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||||
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
|
notificationTime = notificationTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NotificationPreferences.toDto(): NotificationSettingsDto = NotificationSettingsDto(
|
||||||
|
newNews = newNews,
|
||||||
|
newEvents = newEvents,
|
||||||
|
eventsToday = eventsToday,
|
||||||
|
eventsTomorrow = eventsTomorrow,
|
||||||
|
ownTeamMatches = ownTeamMatches,
|
||||||
|
allTeamMatches = allTeamMatches,
|
||||||
|
birthdays = birthdays,
|
||||||
|
newContactRequest = newContactRequest,
|
||||||
|
newUserRegistration = newUserRegistration,
|
||||||
|
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||||
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
|
notificationTime = notificationTime,
|
||||||
|
)
|
||||||
@@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor(
|
|||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
retryOnNetworkFailure {
|
||||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||||
)
|
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
)
|
||||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||||
|
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||||
|
|
||||||
val credentialManager = CredentialManager.create(context)
|
val credentialManager = CredentialManager.create(context)
|
||||||
val credentialResponse = credentialManager.getCredential(
|
val credentialResponse = credentialManager.getCredential(
|
||||||
context = context,
|
context = context,
|
||||||
request = GetCredentialRequest(
|
request = GetCredentialRequest(
|
||||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||||
|
|
||||||
val response = api.passkeyLogin(
|
val response = api.passkeyLogin(
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||||
.put("client", "android")
|
.put("client", "android")
|
||||||
.put("deviceName", "Harheimer TC Android-App")
|
.put("deviceName", "Harheimer TC Android-App")
|
||||||
.toString()
|
.toString()
|
||||||
.toRequestBody(MediaTypes.json),
|
.toRequestBody(MediaTypes.json),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||||
body
|
body
|
||||||
|
}
|
||||||
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
||||||
|
|
||||||
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
||||||
val response = api.passkeys()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
val response = api.passkeys()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
||||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
retryOnNetworkFailure {
|
||||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||||
|
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||||
|
|
||||||
val credentialManager = CredentialManager.create(context)
|
val credentialManager = CredentialManager.create(context)
|
||||||
val credentialResponse = credentialManager.createCredential(
|
val credentialResponse = credentialManager.createCredential(
|
||||||
context = context,
|
context = context,
|
||||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||||
) as? CreatePublicKeyCredentialResponse
|
) as? CreatePublicKeyCredentialResponse
|
||||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||||
|
|
||||||
val response = api.registerPasskey(
|
val response = api.registerPasskey(
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||||
.put("name", name)
|
.put("name", name)
|
||||||
.put("client", "android")
|
.put("client", "android")
|
||||||
.toString()
|
.toString()
|
||||||
.toRequestBody(MediaTypes.json),
|
.toRequestBody(MediaTypes.json),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||||
response.body() ?: error("Leere Antwort")
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
||||||
|
|
||||||
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun load(): Result<ProfileResponse> = runCatching {
|
suspend fun load(): Result<ProfileResponse> = runCatching {
|
||||||
val response = api.profile()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
val response = api.profile()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
||||||
val response = api.updateProfile(request)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
val response = api.updateProfile(request)
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,43 +31,49 @@ data class MeisterschaftResult(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||||
val response = api.config()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
val response = api.config()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||||
val response = api.spielsysteme()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
val response = api.spielsysteme()
|
||||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||||
if (values.size < 8) return@mapNotNull null
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
Spielsystem(
|
if (values.size < 8) return@mapNotNull null
|
||||||
name = values[0],
|
Spielsystem(
|
||||||
description = values[1],
|
name = values[0],
|
||||||
teamSize = values[2],
|
description = values[1],
|
||||||
category = values[3],
|
teamSize = values[2],
|
||||||
sequence = values[5],
|
category = values[3],
|
||||||
gameCount = values[6],
|
sequence = values[5],
|
||||||
features = values[7],
|
gameCount = values[6],
|
||||||
)
|
features = values[7],
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||||
val response = api.vereinsmeisterschaften()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
val response = api.vereinsmeisterschaften()
|
||||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||||
if (values.size < 6) return@mapNotNull null
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
MeisterschaftResult(
|
if (values.size < 6) return@mapNotNull null
|
||||||
year = values[0],
|
MeisterschaftResult(
|
||||||
category = values[1],
|
year = values[0],
|
||||||
rank = values[2],
|
category = values[1],
|
||||||
playerOne = values[3],
|
rank = values[2],
|
||||||
playerTwo = values[4],
|
playerOne = values[3],
|
||||||
note = values[5],
|
playerTwo = values[4],
|
||||||
imageOne = values.getOrElse(6) { "" },
|
note = values[5],
|
||||||
imageTwo = values.getOrElse(7) { "" },
|
imageOne = values.getOrElse(6) { "" },
|
||||||
)
|
imageTwo = values.getOrElse(7) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.PushTokenRequest
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PushTokenRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
) {
|
||||||
|
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||||
|
val token = FirebaseMessaging.getInstance().token.await()
|
||||||
|
registerToken(token).getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun registerToken(token: String): Result<Unit> = runCatching {
|
||||||
|
if (token.isBlank()) return@runCatching
|
||||||
|
retryOnNetworkFailure {
|
||||||
|
val response = api.registerPushToken(
|
||||||
|
PushTokenRequest(
|
||||||
|
token = token,
|
||||||
|
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,18 +9,22 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||||
val response = api.spielplan(season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplan(season)
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
body
|
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||||
val response = api.spielplanTable(team, season)
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.spielplanTable(team, season)
|
||||||
val body = response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
body
|
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||||
val response = api.termine()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
val response = api.termine()
|
||||||
response.body()?.termine.orEmpty()
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||||
val response = api.config()
|
retryOnNetworkFailure {
|
||||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
val response = api.config()
|
||||||
response.body() ?: error("Leere Antwort")
|
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.R
|
import de.harheimertc.R
|
||||||
@@ -42,14 +43,17 @@ private enum class MenuSection {
|
|||||||
TRAINING,
|
TRAINING,
|
||||||
NEWSLETTER,
|
NEWSLETTER,
|
||||||
INTERN,
|
INTERN,
|
||||||
|
CMS,
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class MenuTarget(val label: String, val route: String)
|
private data class MenuTarget(val label: String, val route: String)
|
||||||
|
private const val LOGOUT_ROUTE = "__logout__"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigationHeader(
|
fun AppNavigationHeader(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit = {},
|
||||||
webTabletNavigation: Boolean = false,
|
webTabletNavigation: Boolean = false,
|
||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
@@ -61,9 +65,9 @@ fun AppNavigationHeader(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (webTabletNavigation) {
|
if (webTabletNavigation) {
|
||||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||||
} else {
|
} else {
|
||||||
CompactNavigation(selectedRoute, onNavigate, navigationState)
|
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,110 +76,56 @@ fun AppNavigationHeader(
|
|||||||
private fun CompactNavigation(
|
private fun CompactNavigation(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
val routeSection = menuSection(selectedRoute)
|
val routeSection = menuSection(selectedRoute)
|
||||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
val section = routeSection ?: sectionOverride.value
|
val section = routeSection ?: sectionOverride.value
|
||||||
val subItems = submenu(section, navigationState)
|
val subItems = submenu(section, navigationState)
|
||||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
|
||||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
|
||||||
val mainScroll = rememberScrollState()
|
val mainScroll = rememberScrollState()
|
||||||
val subScroll = rememberScrollState()
|
val subScroll = rememberScrollState()
|
||||||
val cmsSubScroll = rememberScrollState()
|
|
||||||
|
|
||||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
BrandRow(
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
loggedIn = navigationState.loggedIn,
|
||||||
Row(
|
onLogin = { onNavigate(Destinations.Login.route) },
|
||||||
modifier = Modifier.horizontalScroll(mainScroll),
|
onLogout = onLogout,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
)
|
||||||
) {
|
|
||||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
|
||||||
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
|
||||||
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
|
||||||
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
|
||||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
|
||||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
|
||||||
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
|
||||||
if (navigationState.showGallery) {
|
|
||||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
|
||||||
}
|
|
||||||
if (navigationState.loggedIn) {
|
|
||||||
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
|
||||||
}
|
|
||||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
|
||||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
|
||||||
CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mainScroll.canScrollBackward) {
|
ScrollableMenuRow(scrollState = mainScroll) {
|
||||||
Text(
|
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
"◀",
|
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
color = Color(0xFFD4D4D8),
|
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
style = MaterialTheme.typography.labelSmall,
|
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||||
modifier = Modifier
|
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
.align(Alignment.CenterStart)
|
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||||
.background(Color(0x66000000), RoundedCornerShape(8.dp))
|
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
if (navigationState.showGallery) {
|
||||||
)
|
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||||
}
|
}
|
||||||
if (mainScroll.canScrollForward) {
|
if (navigationState.loggedIn) {
|
||||||
Text(
|
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||||
"▶",
|
if (navigationState.canAccessCms) {
|
||||||
color = Color(0xFFD4D4D8),
|
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
|
||||||
style = MaterialTheme.typography.labelSmall,
|
}
|
||||||
modifier = Modifier
|
} else {
|
||||||
.align(Alignment.CenterEnd)
|
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
.background(Color(0x66000000), RoundedCornerShape(8.dp))
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
|
subItems.forEach { item ->
|
||||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
cmsExpanded.value = true
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
}
|
onLogout()
|
||||||
|
} else {
|
||||||
Row(
|
onNavigate(item.route)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(subScroll)
|
|
||||||
.padding(top = 3.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
subItems.forEachIndexed { idx, item ->
|
|
||||||
if (idx == cmsIndex) {
|
|
||||||
SubLink(item.label, item.route == selectedRoute) {
|
|
||||||
cmsExpanded.value = !cmsExpanded.value
|
|
||||||
}
|
}
|
||||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
|
||||||
// CMS children are rendered below when expanded.
|
|
||||||
} else {
|
|
||||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (subItems.isNotEmpty()) {
|
|
||||||
ScrollHintRow(subScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(cmsSubScroll)
|
|
||||||
.padding(top = 6.dp, bottom = 3.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
cmsChildren.forEach { child ->
|
|
||||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ScrollHintRow(cmsSubScroll)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -206,12 +156,13 @@ private fun CompactSectionLink(
|
|||||||
private fun WebTabletNavigation(
|
private fun WebTabletNavigation(
|
||||||
selectedRoute: String?,
|
selectedRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
navigationState: NavigationUiState,
|
navigationState: NavigationUiState,
|
||||||
) {
|
) {
|
||||||
val section = menuSection(selectedRoute)
|
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
val section = sectionOverride.value ?: menuSection(selectedRoute)
|
||||||
// Helper that closes the CMS submenu when navigating away
|
val subScroll = rememberScrollState()
|
||||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Brand()
|
Brand()
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
@@ -220,74 +171,91 @@ private fun WebTabletNavigation(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
|
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
|
||||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
|
sectionOverride.value = null
|
||||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
|
onNavigate(Destinations.Home.route)
|
||||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
|
})
|
||||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
|
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.VereinAbout.route)
|
||||||
|
})
|
||||||
|
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.MANNSCHAFTEN
|
||||||
|
onNavigate(Destinations.Mannschaften.route)
|
||||||
|
})
|
||||||
|
MainLink("Training", section == MenuSection.TRAINING, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.TRAINING
|
||||||
|
onNavigate(Destinations.Training.route)
|
||||||
|
})
|
||||||
|
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Termine.route)
|
||||||
|
})
|
||||||
if (navigationState.showGallery) {
|
if (navigationState.showGallery) {
|
||||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.VEREIN
|
||||||
|
onNavigate(Destinations.Gallery.route)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
|
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
|
||||||
|
sectionOverride.value = MenuSection.NEWSLETTER
|
||||||
|
onNavigate(Destinations.NewsletterSubscribe.route)
|
||||||
|
})
|
||||||
if (navigationState.loggedIn) {
|
if (navigationState.loggedIn) {
|
||||||
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
|
MainLink("Intern", section == MenuSection.INTERN, onClick = {
|
||||||
}
|
sectionOverride.value = MenuSection.INTERN
|
||||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
|
})
|
||||||
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
if (navigationState.canAccessCms) {
|
||||||
}
|
MainLink("CMS", section == MenuSection.CMS, onClick = {
|
||||||
}
|
sectionOverride.value = MenuSection.CMS
|
||||||
val subItems = submenu(section, navigationState)
|
onNavigate(Destinations.Cms.route)
|
||||||
// determine CMS parent index and children
|
})
|
||||||
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
|
|
||||||
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
|
|
||||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
|
||||||
cmsExpanded.value = true
|
|
||||||
}
|
|
||||||
// First row: render all subitems but do NOT render CMS children here
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(top = 3.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
subItems.forEachIndexed { idx, item ->
|
|
||||||
if (idx == cmsIndex) {
|
|
||||||
// CMS parent toggle
|
|
||||||
SubLink(item.label, item.route == selectedRoute) {
|
|
||||||
cmsExpanded.value = !cmsExpanded.value
|
|
||||||
}
|
}
|
||||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
|
||||||
// skip cms children here; they'll be rendered in the second row when expanded
|
|
||||||
} else {
|
|
||||||
// normal item before CMS: close cms submenu on navigate
|
|
||||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
|
||||||
}
|
}
|
||||||
|
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Contact.route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = {
|
||||||
|
sectionOverride.value = null
|
||||||
|
onNavigate(Destinations.Login.route)
|
||||||
|
}) { Text("Login", color = Color.White) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second row: when CMS expanded, render its children beneath
|
val subItems = submenu(section, navigationState)
|
||||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||||
Row(
|
subItems.forEach { item ->
|
||||||
modifier = Modifier
|
SubLink(item.label, item.route == selectedRoute) {
|
||||||
.fillMaxWidth()
|
if (item.route == LOGOUT_ROUTE) {
|
||||||
.horizontalScroll(rememberScrollState())
|
onLogout()
|
||||||
.padding(top = 6.dp, bottom = 3.dp),
|
} else {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
onNavigate(item.route)
|
||||||
) {
|
}
|
||||||
cmsChildren.forEach { child ->
|
|
||||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BrandRow(onLogin: () -> Unit) {
|
private fun BrandRow(
|
||||||
|
loggedIn: Boolean,
|
||||||
|
onLogin: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Brand()
|
Brand()
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
if (loggedIn) {
|
||||||
|
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,25 +335,36 @@ private fun CompactLink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScrollHintRow(scrollState: ScrollState) {
|
private fun ScrollableMenuRow(
|
||||||
if (!scrollState.canScrollBackward && !scrollState.canScrollForward) return
|
scrollState: ScrollState,
|
||||||
|
topPadding: Dp = 0.dp,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 2.dp),
|
.padding(top = topPadding),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (scrollState.canScrollBackward) "◀" else "",
|
if (scrollState.canScrollBackward) "◀" else "",
|
||||||
color = Color(0xFFD4D4D8),
|
color = Color(0xFFD4D4D8),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.horizontalScroll(scrollState),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = content,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
if (scrollState.canScrollForward) "▶" else "",
|
if (scrollState.canScrollForward) "▶" else "",
|
||||||
color = Color(0xFFD4D4D8),
|
color = Color(0xFFD4D4D8),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.width(14.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,12 +410,15 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.NewsletterConfirm.route,
|
Destinations.NewsletterConfirm.route,
|
||||||
Destinations.NewsletterConfirmed.route,
|
Destinations.NewsletterConfirmed.route,
|
||||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||||
|
|
||||||
Destinations.MemberArea.route,
|
Destinations.MemberArea.route,
|
||||||
Destinations.Members.route,
|
Destinations.Members.route,
|
||||||
Destinations.Qttr.route,
|
Destinations.Qttr.route,
|
||||||
Destinations.MemberNews.route,
|
Destinations.MemberNews.route,
|
||||||
Destinations.Profile.route,
|
Destinations.Profile.route,
|
||||||
Destinations.MemberApi.route,
|
Destinations.NotificationSettings.route,
|
||||||
|
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||||
|
|
||||||
Destinations.CmsStartseite.route,
|
Destinations.CmsStartseite.route,
|
||||||
Destinations.CmsInhalte.route,
|
Destinations.CmsInhalte.route,
|
||||||
Destinations.CmsVereinsmeisterschaften.route,
|
Destinations.CmsVereinsmeisterschaften.route,
|
||||||
@@ -447,7 +429,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.CmsEinstellungen.route,
|
Destinations.CmsEinstellungen.route,
|
||||||
Destinations.CmsBenutzer.route,
|
Destinations.CmsBenutzer.route,
|
||||||
Destinations.CmsPasswordResetDiagnostics.route,
|
Destinations.CmsPasswordResetDiagnostics.route,
|
||||||
Destinations.Cms.route -> MenuSection.INTERN
|
Destinations.Cms.route -> MenuSection.CMS
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}.let { section ->
|
}.let { section ->
|
||||||
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
||||||
@@ -464,43 +447,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
MenuTarget("Links", Destinations.Links.route),
|
MenuTarget("Links", Destinations.Links.route),
|
||||||
MenuTarget("Impressum", Destinations.Impressum.route),
|
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.MANNSCHAFTEN -> listOf(
|
MenuSection.MANNSCHAFTEN -> listOf(
|
||||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.TRAINING -> listOf(
|
MenuSection.TRAINING -> listOf(
|
||||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.NEWSLETTER -> listOf(
|
MenuSection.NEWSLETTER -> listOf(
|
||||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||||
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||||
)
|
)
|
||||||
|
|
||||||
MenuSection.INTERN -> buildList {
|
MenuSection.INTERN -> buildList {
|
||||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||||
add(MenuTarget("QTTR", Destinations.Qttr.route))
|
add(MenuTarget("QTTR", Destinations.Qttr.route))
|
||||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||||
|
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
|
}
|
||||||
// CMS child items (will be rendered when CMS parent is expanded)
|
|
||||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
|
MenuSection.CMS -> buildList {
|
||||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
if (state.canAccessFullCms) {
|
||||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||||
|
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||||
|
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||||
|
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||||
|
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||||
|
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
|
||||||
|
}
|
||||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||||
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
|
||||||
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
|
|
||||||
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> emptyList()
|
null -> emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.theme.Primary600
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LoadingState(message: String = "Daten werden geladen...") {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Primary600)
|
||||||
|
Text(message, color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
|
|||||||
object Links : Destinations("verein/links")
|
object Links : Destinations("verein/links")
|
||||||
object Impressum : Destinations("impressum")
|
object Impressum : Destinations("impressum")
|
||||||
object Mannschaften : Destinations("mannschaften")
|
object Mannschaften : Destinations("mannschaften")
|
||||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
|
||||||
fun create(slug: String): String = "mannschaften/$slug"
|
fun create(slug: String, season: String? = null): String {
|
||||||
|
val encodedSlug = android.net.Uri.encode(slug)
|
||||||
|
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
|
||||||
|
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||||
fun create(slug: String): String = "mannschaft/$slug"
|
fun create(slug: String): String = "mannschaft/$slug"
|
||||||
@@ -39,6 +43,7 @@ sealed class Destinations(val route: String) {
|
|||||||
object Qttr : Destinations("intern/qttr")
|
object Qttr : Destinations("intern/qttr")
|
||||||
object MemberNews : Destinations("intern/news")
|
object MemberNews : Destinations("intern/news")
|
||||||
object Profile : Destinations("intern/profil")
|
object Profile : Destinations("intern/profil")
|
||||||
|
object NotificationSettings : Destinations("intern/benachrichtigungen")
|
||||||
object MemberApi : Destinations("intern/api")
|
object MemberApi : Destinations("intern/api")
|
||||||
object CmsStartseite : Destinations("cms/startseite")
|
object CmsStartseite : Destinations("cms/startseite")
|
||||||
object CmsInhalte : Destinations("cms/inhalte")
|
object CmsInhalte : Destinations("cms/inhalte")
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ 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.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -26,7 +33,9 @@ fun NavGraph(
|
|||||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||||
val route = backStackEntry?.destination?.route
|
val route = backStackEntry?.destination?.route
|
||||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||||
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
|
backStackEntry.arguments?.getString("slug")?.let { slug ->
|
||||||
|
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
|
||||||
|
}
|
||||||
} else route
|
} else route
|
||||||
val navigationState by navigationViewModel.state.collectAsState()
|
val navigationState by navigationViewModel.state.collectAsState()
|
||||||
LaunchedEffect(currentRoute) {
|
LaunchedEffect(currentRoute) {
|
||||||
@@ -35,10 +44,27 @@ fun NavGraph(
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val persistentNavigation = maxWidth >= 600.dp
|
val persistentNavigation = maxWidth >= 600.dp
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
navigationState.connectionNote?.let { message ->
|
||||||
|
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = Color(0xFF7C2D12),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (persistentNavigation) {
|
if (persistentNavigation) {
|
||||||
AppNavigationHeader(
|
AppNavigationHeader(
|
||||||
selectedRoute = currentRoute,
|
selectedRoute = currentRoute,
|
||||||
onNavigate = navController::navigateTopLevel,
|
onNavigate = navController::navigateTopLevel,
|
||||||
|
onLogout = {
|
||||||
|
navigationViewModel.logout {
|
||||||
|
navController.navigate(Destinations.Home.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
webTabletNavigation = true,
|
webTabletNavigation = true,
|
||||||
navigationState = navigationState,
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
@@ -52,6 +78,8 @@ fun NavGraph(
|
|||||||
de.harheimertc.ui.screens.home.HomeScreen(
|
de.harheimertc.ui.screens.home.HomeScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showNavigationHeader = !persistentNavigation,
|
showNavigationHeader = !persistentNavigation,
|
||||||
|
navigationViewModel = navigationViewModel,
|
||||||
|
viewModel = hiltViewModel(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.VereinAbout.route) {
|
composable(Destinations.VereinAbout.route) {
|
||||||
@@ -111,9 +139,13 @@ fun NavGraph(
|
|||||||
composable("mannschaften/jugend") {
|
composable("mannschaften/jugend") {
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||||
}
|
}
|
||||||
composable(Destinations.MannschaftDetail.route) { entry ->
|
composable(
|
||||||
|
route = Destinations.MannschaftDetail.route,
|
||||||
|
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
|
||||||
|
) { entry ->
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
|
season = entry.arguments?.getString("season"),
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
@@ -121,6 +153,7 @@ fun NavGraph(
|
|||||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
|
season = null,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
@@ -253,6 +286,7 @@ fun NavGraph(
|
|||||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.Members.route) {
|
composable(Destinations.Members.route) {
|
||||||
@@ -279,6 +313,13 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.NotificationSettings.route) {
|
||||||
|
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
navigationState = navigationState,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Destinations.MemberApi.route) {
|
composable(Destinations.MemberApi.route) {
|
||||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package de.harheimertc.ui.navigation
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
import de.harheimertc.repositories.AuthRepository
|
||||||
import de.harheimertc.repositories.GalleryRepository
|
import de.harheimertc.repositories.GalleryRepository
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -18,10 +21,13 @@ data class NavigationUiState(
|
|||||||
val hasGalleryImages: Boolean = false,
|
val hasGalleryImages: Boolean = false,
|
||||||
val loggedIn: Boolean = false,
|
val loggedIn: Boolean = false,
|
||||||
val roles: Set<String> = emptySet(),
|
val roles: Set<String> = emptySet(),
|
||||||
|
val connectionNote: String? = null,
|
||||||
) {
|
) {
|
||||||
val isAdmin: Boolean get() = "admin" in roles
|
val isAdmin: Boolean get() = "admin" in roles
|
||||||
|
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||||
|
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
|
||||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +36,24 @@ class NavigationViewModel @Inject constructor(
|
|||||||
private val mannschaftenRepository: MannschaftenRepository,
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
private val galleryRepository: GalleryRepository,
|
private val galleryRepository: GalleryRepository,
|
||||||
private val loginRepository: LoginRepository,
|
private val loginRepository: LoginRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
|
private val pushTokenRepository: PushTokenRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NavigationUiState())
|
private val _state = MutableStateFlow(NavigationUiState())
|
||||||
val state: StateFlow<NavigationUiState> = _state
|
val state: StateFlow<NavigationUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadNavigationData()
|
loadNavigationData()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
|
||||||
|
)
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadNavigationData() {
|
fun loadNavigationData() {
|
||||||
@@ -44,22 +62,54 @@ class NavigationViewModel @Inject constructor(
|
|||||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||||
val status = auth.await()
|
val status = auth.await()
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||||
_state.value = NavigationUiState(
|
_state.value = NavigationUiState(
|
||||||
teams = teams.await(),
|
teams = teams.await(),
|
||||||
hasGalleryImages = gallery.await(),
|
hasGalleryImages = gallery.await(),
|
||||||
loggedIn = status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = null,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshSession() {
|
fun refreshSession() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||||
|
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
loggedIn = status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
roles = status.navigationRoles(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPushToken() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
pushTokenRepository.registerCurrentDevice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onComplete: () -> Unit = {}) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loginRepository.logout()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loggedIn = false,
|
||||||
|
roles = emptySet(),
|
||||||
|
connectionNote = _state.value.connectionNote,
|
||||||
|
)
|
||||||
|
onComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
|
||||||
|
addAll(roles)
|
||||||
|
role?.takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
addAll(user?.roles.orEmpty())
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -112,7 +112,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||||
if (state.loading) item { CircularProgressIndicator() }
|
if (state.loading) item { LoadingState("News werden geladen...") }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -50,6 +49,7 @@ 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.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
@@ -67,7 +67,7 @@ import java.util.Locale
|
|||||||
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
|
||||||
item { CmsSummaryGrid(navController, state) }
|
item { CmsSummaryGrid(navController, state) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
|
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -171,7 +171,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
@@ -262,7 +262,7 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
|
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
|
||||||
when {
|
when {
|
||||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -456,7 +456,7 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -555,7 +555,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
|
|||||||
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
|
||||||
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
||||||
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
||||||
}
|
}
|
||||||
@@ -592,7 +592,7 @@ fun CmsNewsletterScreen(
|
|||||||
var grpTargetGroup by remember { mutableStateOf("") }
|
var grpTargetGroup by remember { mutableStateOf("") }
|
||||||
var grpSendToExternal by remember { mutableStateOf(true) }
|
var grpSendToExternal by remember { mutableStateOf(true) }
|
||||||
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
||||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
|
||||||
item {
|
item {
|
||||||
if (canWrite) Button(onClick = {
|
if (canWrite) Button(onClick = {
|
||||||
editingNewsletter = null
|
editingNewsletter = null
|
||||||
@@ -769,7 +769,7 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
|
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
|
||||||
when {
|
when {
|
||||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
|
||||||
else -> {
|
else -> {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -883,7 +883,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
item { CircularProgressIndicator(color = Primary600) }
|
item { LoadingState("Diagnosedaten werden geladen...") }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.passwordResetSearchTerm.isNotBlank()) {
|
if (state.passwordResetSearchTerm.isNotBlank()) {
|
||||||
@@ -1073,7 +1073,7 @@ private fun CmsConfigPage(
|
|||||||
) {
|
) {
|
||||||
CmsPage(navController, showBackNavigation, title, subtitle) {
|
CmsPage(navController, showBackNavigation, title, subtitle) {
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
item { CircularProgressIndicator(color = Primary600) }
|
item { LoadingState("Konfiguration wird geladen...") }
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
import de.harheimertc.data.CmsUserDto
|
import de.harheimertc.data.CmsUserDto
|
||||||
import de.harheimertc.data.ConfigResponse
|
import de.harheimertc.data.ConfigResponse
|
||||||
import de.harheimertc.data.ContactRequestDto
|
import de.harheimertc.data.ContactRequestDto
|
||||||
@@ -43,12 +44,22 @@ data class CmsUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CmsViewModel @Inject constructor(
|
class CmsViewModel @Inject constructor(
|
||||||
private val repository: CmsRepository,
|
private val repository: CmsRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(CmsUiState())
|
private val _state = MutableStateFlow(CmsUiState())
|
||||||
val state: StateFlow<CmsUiState> = _state
|
val state: StateFlow<CmsUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import de.harheimertc.R
|
import de.harheimertc.R
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.ImageGrid
|
import de.harheimertc.ui.components.ImageGrid
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
CircularProgressIndicator()
|
LoadingState("Galerie wird geladen...")
|
||||||
} else if (images.isEmpty()) {
|
} else if (images.isEmpty()) {
|
||||||
Text(text = stringResource(R.string.gallery_empty))
|
Text(text = stringResource(R.string.gallery_empty))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
@@ -77,9 +76,9 @@ import java.util.Locale
|
|||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
showNavigationHeader: Boolean = true,
|
showNavigationHeader: Boolean = true,
|
||||||
viewModel: HomeViewModel = hiltViewModel(),
|
navigationViewModel: NavigationViewModel,
|
||||||
|
viewModel: HomeViewModel,
|
||||||
) {
|
) {
|
||||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
|
||||||
val navigationState by navigationViewModel.state.collectAsState()
|
val navigationState by navigationViewModel.state.collectAsState()
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||||
@@ -107,6 +106,13 @@ fun HomeScreen(
|
|||||||
AppNavigationHeader(
|
AppNavigationHeader(
|
||||||
selectedRoute = Destinations.Home.route,
|
selectedRoute = Destinations.Home.route,
|
||||||
onNavigate = navController::navigate,
|
onNavigate = navController::navigate,
|
||||||
|
onLogout = {
|
||||||
|
navigationViewModel.logout {
|
||||||
|
navController.navigate(Destinations.Home.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationState = navigationState,
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fun PasswordResetScreen(
|
|||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
AuthFormPage(
|
AuthFormPage(
|
||||||
title = "Passwort zurücksetzen",
|
title = "Passwort zurücksetzen",
|
||||||
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.",
|
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
|
||||||
onBack = { navController.navigate(Destinations.Login.route) },
|
onBack = { navController.navigate(Destinations.Login.route) },
|
||||||
showBackNavigation = showBackNavigation,
|
showBackNavigation = showBackNavigation,
|
||||||
) {
|
) {
|
||||||
@@ -68,7 +68,7 @@ fun PasswordResetScreen(
|
|||||||
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
|
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Zurück zum Login")
|
Text("Zurück zum Login")
|
||||||
}
|
}
|
||||||
AuthNotice("Sie erhalten eine E-Mail mit einem temporären Passwort, sofern ein Konto vorhanden ist.")
|
AuthNotice("Sie erhalten eine E-Mail mit einem Reset-Link, sofern ein Konto vorhanden ist. Ihr bisheriges Passwort bleibt bis zur Änderung gültig.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -27,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -34,15 +37,18 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.data.SpielDto
|
import de.harheimertc.data.SpielDto
|
||||||
import de.harheimertc.data.LeagueTableRowDto
|
import de.harheimertc.data.LeagueTableRowDto
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.theme.Accent700
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
import de.harheimertc.ui.theme.Primary100
|
import de.harheimertc.ui.theme.Primary100
|
||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
@@ -64,13 +70,22 @@ fun MannschaftenScreen(
|
|||||||
BackLink(navController, showBackNavigation)
|
BackLink(navController, showBackNavigation)
|
||||||
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||||
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
if (state.seasons.isNotEmpty()) {
|
||||||
|
SeasonSelector(
|
||||||
|
seasons = state.seasons,
|
||||||
|
selectedSeason = state.selectedSeason,
|
||||||
|
onSeasonSelected = viewModel::selectSeason,
|
||||||
|
modifier = Modifier.padding(top = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
|
state.seasonsLoading -> item { Loading() }
|
||||||
state.loading -> item { Loading() }
|
state.loading -> item { Loading() }
|
||||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||||
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
||||||
else -> items(state.teams) { team ->
|
else -> items(state.teams) { team ->
|
||||||
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug)) }
|
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug, state.selectedSeason)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -88,6 +103,38 @@ fun MannschaftenScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeasonSelector(
|
||||||
|
seasons: List<de.harheimertc.data.SeasonDto>,
|
||||||
|
selectedSeason: String,
|
||||||
|
onSeasonSelected: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||||
|
BoxWithConstraints {
|
||||||
|
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
|
||||||
|
Text("v")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
seasons.forEach { season ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(season.label.ifBlank { season.slug }) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onSeasonSelected(season.slug)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -114,13 +161,14 @@ private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun MannschaftDetailScreen(
|
fun MannschaftDetailScreen(
|
||||||
slug: String,
|
slug: String,
|
||||||
|
season: String?,
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
showBackNavigation: Boolean,
|
showBackNavigation: Boolean,
|
||||||
viewModel: MannschaftDetailViewModel = hiltViewModel(),
|
viewModel: MannschaftDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) }
|
var selectedTab by rememberSaveable(slug, season) { mutableStateOf(DetailTab.Matches) }
|
||||||
LaunchedEffect(slug) { viewModel.load(slug) }
|
LaunchedEffect(slug, season) { viewModel.load(slug, season) }
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||||
@@ -182,7 +230,7 @@ fun MannschaftDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } }
|
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug, season) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,9 +425,7 @@ private fun BackLink(navController: NavController, visible: Boolean) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Loading() {
|
private fun Loading() {
|
||||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
LoadingState("Mannschaftsdaten werden geladen...")
|
||||||
CircularProgressIndicator(color = Primary600)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.data.SpielDto
|
import de.harheimertc.data.SpielDto
|
||||||
import de.harheimertc.data.LeagueTableRowDto
|
import de.harheimertc.data.LeagueTableRowDto
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
import de.harheimertc.repositories.SpielplanRepository
|
import de.harheimertc.repositories.SpielplanRepository
|
||||||
@@ -17,6 +18,9 @@ data class MannschaftenUiState(
|
|||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val teams: List<Mannschaft> = emptyList(),
|
val teams: List<Mannschaft> = emptyList(),
|
||||||
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
|
val selectedSeason: String = "",
|
||||||
|
val seasonsLoading: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -27,17 +31,76 @@ class MannschaftenViewModel @Inject constructor(
|
|||||||
val state: StateFlow<MannschaftenUiState> = _state
|
val state: StateFlow<MannschaftenUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
loadSeasonsAndMannschaften()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = MannschaftenUiState(loading = true)
|
val season = _state.value.selectedSeason.ifBlank { null }
|
||||||
repository.fetchMannschaften()
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) }
|
repository.fetchMannschaften(season)
|
||||||
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
.onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) }
|
||||||
|
.onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectSeason(season: String) {
|
||||||
|
if (season == _state.value.selectedSeason) return
|
||||||
|
_state.value = _state.value.copy(selectedSeason = season)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSeasonsAndMannschaften() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(seasonsLoading = true, error = null)
|
||||||
|
repository.fetchSeasons()
|
||||||
|
.onSuccess { response ->
|
||||||
|
val currentSeason = getCurrentSeasonSlug()
|
||||||
|
val seasons = response.seasons
|
||||||
|
.map { season -> SeasonDto(slug = season, label = formatSeasonLabel(season)) }
|
||||||
|
.ifEmpty {
|
||||||
|
val fallbackSeason = response.currentSeason.ifBlank { currentSeason }
|
||||||
|
listOf(SeasonDto(slug = fallbackSeason, label = formatSeasonLabel(fallbackSeason)))
|
||||||
|
}
|
||||||
|
val serverCurrentSeason = response.currentSeason.ifBlank { currentSeason }
|
||||||
|
val selectedSeason = when {
|
||||||
|
seasons.any { it.slug == currentSeason } -> currentSeason
|
||||||
|
seasons.any { it.slug == serverCurrentSeason } -> serverCurrentSeason
|
||||||
|
response.defaultSeason.isNotBlank() -> response.defaultSeason
|
||||||
|
seasons.isNotEmpty() -> seasons.first().slug
|
||||||
|
else -> currentSeason
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
seasonsLoading = false,
|
||||||
|
seasons = seasons,
|
||||||
|
selectedSeason = selectedSeason,
|
||||||
|
)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
val currentSeason = getCurrentSeasonSlug()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
seasonsLoading = false,
|
||||||
|
seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))),
|
||||||
|
selectedSeason = currentSeason,
|
||||||
|
)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCurrentSeasonSlug(): String {
|
||||||
|
val now = java.util.Calendar.getInstance()
|
||||||
|
val year = now.get(java.util.Calendar.YEAR)
|
||||||
|
val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1
|
||||||
|
val endYear = startYear + 1
|
||||||
|
return "%02d--%02d".format(startYear % 100, endYear % 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSeasonLabel(seasonSlug: String): String {
|
||||||
|
val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug
|
||||||
|
return "20${match.groupValues[1]}/${match.groupValues[2]}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MannschaftDetailUiState(
|
data class MannschaftDetailUiState(
|
||||||
@@ -58,35 +121,38 @@ class MannschaftDetailViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(MannschaftDetailUiState())
|
private val _state = MutableStateFlow(MannschaftDetailUiState())
|
||||||
val state: StateFlow<MannschaftDetailUiState> = _state
|
val state: StateFlow<MannschaftDetailUiState> = _state
|
||||||
private var loadedSlug: String? = null
|
private var loadedKey: String? = null
|
||||||
|
|
||||||
fun load(slug: String) {
|
fun load(slug: String, season: String? = null) {
|
||||||
if (loadedSlug == slug) return
|
val selectedSeason = season?.takeIf { it.isNotBlank() }
|
||||||
loadedSlug = slug
|
val key = "$slug|${selectedSeason.orEmpty()}"
|
||||||
|
if (loadedKey == key) return
|
||||||
|
loadedKey = key
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = MannschaftDetailUiState(loading = true)
|
_state.value = MannschaftDetailUiState(loading = true, season = selectedSeason)
|
||||||
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug }
|
val team = mannschaftenRepository.fetchMannschaften(selectedSeason).getOrDefault(emptyList()).find { it.slug == slug }
|
||||||
if (team == null) {
|
if (team == null) {
|
||||||
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
|
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
spielplanRepository.fetchSpielplan()
|
spielplanRepository.fetchSpielplan(selectedSeason)
|
||||||
.onSuccess { plan ->
|
.onSuccess { plan ->
|
||||||
_state.value = MannschaftDetailUiState(
|
_state.value = MannschaftDetailUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
team = team,
|
team = team,
|
||||||
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
|
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
|
||||||
season = plan.season,
|
season = plan.season ?: selectedSeason,
|
||||||
)
|
)
|
||||||
if (team.informationenLink.isNotBlank()) {
|
if (team.informationenLink.isNotBlank()) {
|
||||||
loadTable(team, plan.season)
|
loadTable(team, plan.season ?: selectedSeason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_state.value = MannschaftDetailUiState(
|
_state.value = MannschaftDetailUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
team = team,
|
team = team,
|
||||||
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.",
|
season = selectedSeason,
|
||||||
|
matchesError = "Der Spielplan konnte nicht geladen werden.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -39,6 +38,7 @@ import androidx.navigation.NavController
|
|||||||
import de.harheimertc.data.MemberDto
|
import de.harheimertc.data.MemberDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.QttrRowDto
|
import de.harheimertc.data.QttrRowDto
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.RichText
|
import de.harheimertc.ui.components.RichText
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
@@ -163,7 +163,7 @@ fun MembersScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading -> item { LoadingState("Mitglieder werden geladen...") }
|
||||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||||
display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
||||||
else -> if (viewMode == "table") {
|
else -> if (viewMode == "table") {
|
||||||
@@ -200,7 +200,7 @@ fun MemberNewsScreen(
|
|||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") {
|
MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") {
|
||||||
when {
|
when {
|
||||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading -> item { LoadingState("News werden geladen...") }
|
||||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||||
state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) }
|
state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) }
|
||||||
else -> items(state.news.size) { index -> NewsCard(state.news[index]) }
|
else -> items(state.news.size) { index -> NewsCard(state.news[index]) }
|
||||||
@@ -269,7 +269,7 @@ fun QttrScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
state.loading -> item { LoadingState("QTTR-Werte werden geladen...") }
|
||||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||||
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
|
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
|
||||||
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
|
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
import de.harheimertc.data.AuthStatusResponse
|
import de.harheimertc.data.AuthStatusResponse
|
||||||
import de.harheimertc.data.MemberDto
|
import de.harheimertc.data.MemberDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
@@ -24,12 +25,22 @@ data class MembersUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MembersViewModel @Inject constructor(
|
class MembersViewModel @Inject constructor(
|
||||||
private val repository: MemberAreaRepository,
|
private val repository: MemberAreaRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(MembersUiState())
|
private val _state = MutableStateFlow(MembersUiState())
|
||||||
val state: StateFlow<MembersUiState> = _state
|
val state: StateFlow<MembersUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateQuery(query: String) {
|
fun updateQuery(query: String) {
|
||||||
@@ -87,12 +98,22 @@ data class MemberNewsUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MemberNewsViewModel @Inject constructor(
|
class MemberNewsViewModel @Inject constructor(
|
||||||
private val repository: MemberAreaRepository,
|
private val repository: MemberAreaRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(MemberNewsUiState())
|
private val _state = MutableStateFlow(MemberNewsUiState())
|
||||||
val state: StateFlow<MemberNewsUiState> = _state
|
val state: StateFlow<MemberNewsUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
@@ -118,12 +139,22 @@ data class QttrUiState(
|
|||||||
class QttrViewModel @Inject constructor(
|
class QttrViewModel @Inject constructor(
|
||||||
private val repository: MemberAreaRepository,
|
private val repository: MemberAreaRepository,
|
||||||
private val loginRepository: LoginRepository,
|
private val loginRepository: LoginRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(QttrUiState())
|
private val _state = MutableStateFlow(QttrUiState())
|
||||||
val state: StateFlow<QttrUiState> = _state
|
val state: StateFlow<QttrUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -27,8 +26,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.BirthdayDto
|
import de.harheimertc.data.BirthdayDto
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.ui.navigation.NavigationUiState
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent700
|
import de.harheimertc.ui.theme.Accent700
|
||||||
@@ -40,6 +42,7 @@ import de.harheimertc.ui.theme.Primary600
|
|||||||
fun MemberAreaScreen(
|
fun MemberAreaScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
showBackNavigation: Boolean,
|
showBackNavigation: Boolean,
|
||||||
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
viewModel: MemberAreaViewModel = hiltViewModel(),
|
viewModel: MemberAreaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
@@ -63,6 +66,12 @@ fun MemberAreaScreen(
|
|||||||
MemberAreaCardGrid(navController)
|
MemberAreaCardGrid(navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (navigationState.isAdmin) {
|
||||||
|
item {
|
||||||
|
ServerInfoCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
BirthdayCard(
|
BirthdayCard(
|
||||||
birthdays = state.birthdays,
|
birthdays = state.birthdays,
|
||||||
@@ -74,6 +83,16 @@ fun MemberAreaScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerInfoCard() {
|
||||||
|
Surface(color = Primary100, shape = RoundedCornerShape(14.dp), shadowElevation = 2.dp) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Serververbindung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
|
Text(BuildConfig.API_BASE_URL.trimEnd('/'), color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MemberAreaCardGrid(navController: NavController) {
|
private fun MemberAreaCardGrid(navController: NavController) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
@@ -83,6 +102,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
|
|||||||
marker = "P",
|
marker = "P",
|
||||||
onClick = { navController.navigate(Destinations.Profile.route) },
|
onClick = { navController.navigate(Destinations.Profile.route) },
|
||||||
)
|
)
|
||||||
|
MemberAreaCard(
|
||||||
|
title = "Benachrichtigungen",
|
||||||
|
description = "Persönliche Hinweise im Android-System verwalten",
|
||||||
|
marker = "B",
|
||||||
|
onClick = { navController.navigate(Destinations.NotificationSettings.route) },
|
||||||
|
)
|
||||||
MemberAreaCard(
|
MemberAreaCard(
|
||||||
title = "Mitglieder",
|
title = "Mitglieder",
|
||||||
description = "Kontaktdaten der Vereinsmitglieder",
|
description = "Kontaktdaten der Vereinsmitglieder",
|
||||||
@@ -158,8 +183,7 @@ private fun BirthdayCard(
|
|||||||
|
|
||||||
when {
|
when {
|
||||||
loading -> {
|
loading -> {
|
||||||
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
LoadingState("Geburtstage werden geladen...")
|
||||||
Text("Lade...", color = Accent500)
|
|
||||||
}
|
}
|
||||||
error != null -> {
|
error != null -> {
|
||||||
Text(error, color = MaterialTheme.colorScheme.error)
|
Text(error, color = MaterialTheme.colorScheme.error)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
import de.harheimertc.data.BirthdayDto
|
import de.harheimertc.data.BirthdayDto
|
||||||
import de.harheimertc.repositories.MemberAreaRepository
|
import de.harheimertc.repositories.MemberAreaRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,12 +20,22 @@ data class MemberAreaUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MemberAreaViewModel @Inject constructor(
|
class MemberAreaViewModel @Inject constructor(
|
||||||
private val repository: MemberAreaRepository,
|
private val repository: MemberAreaRepository,
|
||||||
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(MemberAreaUiState())
|
private val _state = MutableStateFlow(MemberAreaUiState())
|
||||||
val state: StateFlow<MemberAreaUiState> = _state
|
val state: StateFlow<MemberAreaUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadBirthdays()
|
loadBirthdays()
|
||||||
|
viewModelScope.launch {
|
||||||
|
var wasOnline: Boolean? = null
|
||||||
|
connectivityMonitor.online.collect { online ->
|
||||||
|
if (online && wasOnline == false) {
|
||||||
|
loadBirthdays()
|
||||||
|
}
|
||||||
|
wasOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBirthdays() {
|
fun loadBirthdays() {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.data.NewsletterGroupDto
|
import de.harheimertc.data.NewsletterGroupDto
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.ValidatedTextField
|
import de.harheimertc.ui.components.ValidatedTextField
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
@@ -93,8 +93,7 @@ fun NewsletterConfirmScreen(
|
|||||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") {
|
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") {
|
||||||
when {
|
when {
|
||||||
state.loading -> {
|
state.loading -> {
|
||||||
CircularProgressIndicator(color = Primary600)
|
LoadingState("Newsletter-Anmeldung wird bestätigt...")
|
||||||
Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700)
|
|
||||||
}
|
}
|
||||||
state.error != null -> {
|
state.error != null -> {
|
||||||
Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
@@ -154,7 +153,7 @@ private fun NewsletterFormScreen(
|
|||||||
Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||||
}
|
}
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
item { CircularProgressIndicator(color = Primary600) }
|
item { LoadingState("Newsletter-Daten werden geladen...") }
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package de.harheimertc.ui.screens.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
|
import de.harheimertc.repositories.Mannschaft
|
||||||
|
import de.harheimertc.repositories.NotificationPreferences
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
|
import de.harheimertc.ui.navigation.NavigationUiState
|
||||||
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.theme.Accent700
|
||||||
|
import de.harheimertc.ui.theme.Accent900
|
||||||
|
import de.harheimertc.ui.theme.Primary100
|
||||||
|
import de.harheimertc.ui.theme.Primary600
|
||||||
|
|
||||||
|
private val notificationTimes = (6..22).flatMap { hour ->
|
||||||
|
listOf("%02d:00".format(hour), "%02d:30".format(hour))
|
||||||
|
}.dropLast(1)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationSettingsScreen(
|
||||||
|
navController: NavController,
|
||||||
|
showBackNavigation: Boolean,
|
||||||
|
navigationState: NavigationUiState,
|
||||||
|
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var hasPermission by remember { mutableStateOf(HarheimerNotifications.hasNotificationPermission(context)) }
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
hasPermission = granted || HarheimerNotifications.hasNotificationPermission(context)
|
||||||
|
}
|
||||||
|
val isBoard = "vorstand" in navigationState.roles
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||||
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
if (showBackNavigation) {
|
||||||
|
TextButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Benachrichtigungen", style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||||
|
Text("Persönliche Android-Benachrichtigungen verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NotificationCard("Android-Berechtigung") {
|
||||||
|
val permissionText = if (hasPermission) {
|
||||||
|
"Benachrichtigungen sind im Android-System erlaubt."
|
||||||
|
} else {
|
||||||
|
"Benachrichtigungen sind im Android-System noch nicht erlaubt."
|
||||||
|
}
|
||||||
|
Text(permissionText, color = if (hasPermission) Color(0xFF166534) else Accent700)
|
||||||
|
if (!hasPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Button(
|
||||||
|
onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Berechtigung anfordern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
NotificationCard("Benachrichtigungszeit") {
|
||||||
|
Text("Automatische Benachrichtigungen werden zu dieser Uhrzeit ausgelöst.", color = Accent700)
|
||||||
|
TimeSelection(state.settings.notificationTime) { selectedTime ->
|
||||||
|
viewModel.update(state.settings.copy(notificationTime = selectedTime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NotificationCard("News") {
|
||||||
|
ToggleRow("Neue News", state.settings.newNews) {
|
||||||
|
viewModel.update(state.settings.copy(newNews = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NotificationCard("Termine") {
|
||||||
|
ToggleRow("Neue Termine", state.settings.newEvents) {
|
||||||
|
viewModel.update(state.settings.copy(newEvents = it))
|
||||||
|
}
|
||||||
|
ToggleRow("Termine von heute", state.settings.eventsToday) {
|
||||||
|
viewModel.update(state.settings.copy(eventsToday = it))
|
||||||
|
}
|
||||||
|
ToggleRow("Termine von morgen", state.settings.eventsTomorrow) {
|
||||||
|
viewModel.update(state.settings.copy(eventsTomorrow = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NotificationCard("Punktspiele") {
|
||||||
|
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
|
||||||
|
viewModel.update(state.settings.copy(ownTeamMatches = it))
|
||||||
|
}
|
||||||
|
Text("Die eigene Mannschaft wird aus dem Namen und der Mannschaftszusammensetzung ermittelt.", color = Accent700)
|
||||||
|
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||||
|
viewModel.update(state.settings.copy(allTeamMatches = it))
|
||||||
|
}
|
||||||
|
TeamSelection(
|
||||||
|
teams = state.teams,
|
||||||
|
seasons = state.seasons,
|
||||||
|
selectedSeason = state.settings.selectedTeamSeason,
|
||||||
|
settings = state.settings,
|
||||||
|
onSelectSeason = viewModel::selectSeason,
|
||||||
|
onToggleTeam = viewModel::toggleTeam,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NotificationCard("Mitglieder") {
|
||||||
|
ToggleRow("Geburtstage", state.settings.birthdays) {
|
||||||
|
viewModel.update(state.settings.copy(birthdays = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoard) {
|
||||||
|
item {
|
||||||
|
NotificationCard("Vorstand") {
|
||||||
|
ToggleRow("Neue Kontaktanfrage", state.settings.newContactRequest) {
|
||||||
|
viewModel.update(state.settings.copy(newContactRequest = it))
|
||||||
|
}
|
||||||
|
ToggleRow("Neue Benutzerregistrierung", state.settings.newUserRegistration) {
|
||||||
|
viewModel.update(state.settings.copy(newUserRegistration = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.saveError?.let { message ->
|
||||||
|
item { Text(message, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { message ->
|
||||||
|
item {
|
||||||
|
Text(message, color = MaterialTheme.colorScheme.error)
|
||||||
|
TextButton(onClick = viewModel::load) { Text("Erneut laden") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotificationCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(label, color = Accent900, modifier = Modifier.weight(1f))
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
notificationTimes.forEach { time ->
|
||||||
|
if (time == selectedTime) {
|
||||||
|
Button(onClick = { onSelectTime(time) }) { Text(time) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { onSelectTime(time) }) { Text(time) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TeamSelection(
|
||||||
|
teams: List<Mannschaft>,
|
||||||
|
seasons: List<String>,
|
||||||
|
selectedSeason: String?,
|
||||||
|
settings: NotificationPreferences,
|
||||||
|
onSelectSeason: (String) -> Unit,
|
||||||
|
onToggleTeam: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(color = Primary100, shape = RoundedCornerShape(10.dp)) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("Ausgewählte Mannschaften", color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("Zusätzlich einzelne Mannschaften abonnieren.", color = Accent700)
|
||||||
|
if (seasons.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
seasons.forEach { season ->
|
||||||
|
val selected = season == selectedSeason
|
||||||
|
if (selected) {
|
||||||
|
Button(onClick = { onSelectSeason(season) }) { Text(season) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { onSelectSeason(season) }) { Text(season) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (teams.isEmpty()) {
|
||||||
|
Text("Keine Mannschaften verfügbar.", color = Accent700)
|
||||||
|
} else {
|
||||||
|
teams.forEach { team ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Checkbox(
|
||||||
|
checked = team.slug in settings.selectedTeamSlugs,
|
||||||
|
onCheckedChange = { onToggleTeam(team.slug, it) },
|
||||||
|
)
|
||||||
|
Text(team.mannschaft, color = Accent900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package de.harheimertc.ui.screens.notifications
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.harheimertc.repositories.Mannschaft
|
||||||
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
|
import de.harheimertc.repositories.NotificationPreferences
|
||||||
|
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class NotificationSettingsUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val settings: NotificationPreferences = NotificationPreferences(),
|
||||||
|
val teams: List<Mannschaft> = emptyList(),
|
||||||
|
val seasons: List<String> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val saveError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
|
private val preferencesRepository: NotificationPreferencesRepository,
|
||||||
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||||
|
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||||
|
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
|
||||||
|
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
|
||||||
|
val seasons = seasonsResponse?.seasons.orEmpty()
|
||||||
|
val selectedSeason = storedSettings.selectedTeamSeason
|
||||||
|
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||||
|
?: seasons.firstOrNull()
|
||||||
|
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSeason(season: String) {
|
||||||
|
val current = _state.value.settings
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||||
|
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(settings: NotificationPreferences) {
|
||||||
|
preferencesRepository.saveLocal(settings)
|
||||||
|
_state.value = _state.value.copy(settings = settings, saveError = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.saveRemote(settings)
|
||||||
|
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
|
||||||
|
.onFailure { error ->
|
||||||
|
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleTeam(slug: String, selected: Boolean) {
|
||||||
|
val current = _state.value.settings
|
||||||
|
val nextTeams = if (selected) {
|
||||||
|
current.selectedTeamSlugs + slug
|
||||||
|
} else {
|
||||||
|
current.selectedTeamSlugs - slug
|
||||||
|
}
|
||||||
|
update(current.copy(selectedTeamSlugs = nextTeams))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, syncRemote: Boolean = false) {
|
||||||
|
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
||||||
|
.onSuccess { teams ->
|
||||||
|
val knownSlugs = teams.map { it.slug }.toSet()
|
||||||
|
val nextSettings = settings.copy(selectedTeamSlugs = settings.selectedTeamSlugs.intersect(knownSlugs))
|
||||||
|
preferencesRepository.saveLocal(nextSettings)
|
||||||
|
val saveError = if (syncRemote) {
|
||||||
|
preferencesRepository.saveRemote(nextSettings).exceptionOrNull()?.message
|
||||||
|
} else null
|
||||||
|
_state.value = NotificationSettingsUiState(
|
||||||
|
loading = false,
|
||||||
|
settings = nextSettings,
|
||||||
|
teams = teams,
|
||||||
|
seasons = seasons,
|
||||||
|
saveError = saveError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
preferencesRepository.saveLocal(settings)
|
||||||
|
val saveError = if (syncRemote) {
|
||||||
|
preferencesRepository.saveRemote(settings).exceptionOrNull()?.message
|
||||||
|
} else null
|
||||||
|
_state.value = NotificationSettingsUiState(
|
||||||
|
loading = false,
|
||||||
|
settings = settings,
|
||||||
|
seasons = seasons,
|
||||||
|
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
||||||
|
saveError = saveError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import androidx.navigation.NavController
|
|||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
import de.harheimertc.ui.components.ValidatedTextField
|
import de.harheimertc.ui.components.ValidatedTextField
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -67,9 +68,7 @@ fun ProfileScreen(
|
|||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
item {
|
item {
|
||||||
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
LoadingState("Profil wird geladen...")
|
||||||
CircularProgressIndicator(color = Primary600)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyListScope
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -27,6 +26,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.RichText
|
import de.harheimertc.ui.components.RichText
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
@@ -68,9 +68,7 @@ internal fun PublicCard(title: String? = null, content: @Composable () -> Unit)
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PublicLoading() {
|
internal fun PublicLoading() {
|
||||||
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
LoadingState()
|
||||||
CircularProgressIndicator(color = Primary600)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -43,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.data.SeasonDto
|
import de.harheimertc.data.SeasonDto
|
||||||
import de.harheimertc.data.SpielDto
|
import de.harheimertc.data.SpielDto
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent200
|
import de.harheimertc.ui.theme.Accent200
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
@@ -228,10 +228,7 @@ private fun MatchRow(game: SpielDto) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingPlan() {
|
private fun LoadingPlan() {
|
||||||
Column(Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
LoadingState("Spielpläne werden geladen...")
|
||||||
CircularProgressIndicator(color = Primary600)
|
|
||||||
Text("Spielpläne werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.data.TerminDto
|
import de.harheimertc.data.TerminDto
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent700
|
import de.harheimertc.ui.theme.Accent700
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
@@ -125,10 +125,7 @@ private fun TerminCard(termin: TerminDto) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingPanel() {
|
private fun LoadingPanel() {
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 38.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
LoadingState("Termine werden geladen...")
|
||||||
CircularProgressIndicator(color = Primary600)
|
|
||||||
Text("Termine werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -32,6 +31,7 @@ import androidx.navigation.NavController
|
|||||||
import de.harheimertc.data.TrainingTimeDto
|
import de.harheimertc.data.TrainingTimeDto
|
||||||
import de.harheimertc.data.TrainerDto
|
import de.harheimertc.data.TrainerDto
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent700
|
import de.harheimertc.ui.theme.Accent700
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
@@ -193,9 +193,7 @@ private fun TrainerCard(trainer: TrainerDto) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Loading() {
|
private fun Loading() {
|
||||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
LoadingState("Trainingsdaten werden geladen...")
|
||||||
CircularProgressIndicator(color = Primary600)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ plugins {
|
|||||||
id("com.google.devtools.ksp") version "2.3.7" apply false
|
id("com.google.devtools.ksp") version "2.3.7" apply false
|
||||||
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
|
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||||
|
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
|||||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||||
|
|
||||||
# Android app versioning for Play Store uploads
|
# Android app versioning for Play Store uploads
|
||||||
ANDROID_VERSION_CODE=19
|
ANDROID_VERSION_CODE=25
|
||||||
ANDROID_VERSION_NAME=0.9.14
|
ANDROID_VERSION_NAME=0.9.20
|
||||||
|
|
||||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||||
RELEASE_MINIFY_ENABLED=false
|
RELEASE_MINIFY_ENABLED=false
|
||||||
|
|||||||
@@ -78,53 +78,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- News Modal -->
|
<Teleport to="body">
|
||||||
<div
|
<Transition name="news-modal">
|
||||||
v-if="selectedNews"
|
<div
|
||||||
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
v-if="selectedNews"
|
||||||
@click.self="closeNewsModal"
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/65 px-4 py-6 sm:px-6"
|
||||||
>
|
role="dialog"
|
||||||
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
aria-modal="true"
|
||||||
<!-- Modal Header -->
|
:aria-labelledby="modalTitleId"
|
||||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
@click.self="closeNewsModal"
|
||||||
<div class="flex-1">
|
>
|
||||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
<article class="w-full max-w-3xl max-h-[min(44rem,calc(100vh-3rem))] overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||||
<Calendar
|
<header class="flex items-start gap-4 border-b border-gray-200 bg-white px-5 py-4 sm:px-7 sm:py-6">
|
||||||
:size="16"
|
<div class="min-w-0 flex-1">
|
||||||
class="mr-2"
|
<div class="mb-3 inline-flex items-center gap-2 rounded-full bg-primary-50 px-3 py-1 text-sm font-medium text-primary-800">
|
||||||
/>
|
<Calendar :size="15" />
|
||||||
{{ formatDate(selectedNews.created) }}
|
<time :datetime="selectedNews.created">
|
||||||
</div>
|
{{ formatDate(selectedNews.created) }}
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900">
|
</time>
|
||||||
{{ selectedNews.title }}
|
</div>
|
||||||
</h2>
|
<h2
|
||||||
</div>
|
:id="modalTitleId"
|
||||||
<button
|
class="text-2xl sm:text-3xl font-display font-bold leading-tight text-gray-950 break-words"
|
||||||
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
>
|
||||||
@click="closeNewsModal"
|
{{ selectedNews.title }}
|
||||||
>
|
</h2>
|
||||||
<X :size="24" />
|
</div>
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
class="shrink-0 rounded-md border border-gray-200 bg-white p-2 text-gray-500 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
aria-label="News schließen"
|
||||||
|
@click="closeNewsModal"
|
||||||
|
>
|
||||||
|
<X :size="20" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Modal Content (scrollable) -->
|
<div class="overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
|
||||||
<div class="p-6 overflow-y-auto flex-1">
|
<div class="news-modal-content text-base leading-7 text-gray-800 sm:text-lg sm:leading-8">
|
||||||
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap">
|
{{ selectedNews.content }}
|
||||||
{{ selectedNews.content }}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { Calendar, X } from 'lucide-vue-next'
|
import { Calendar, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
const news = ref([])
|
const news = ref([])
|
||||||
const selectedNews = ref(null)
|
const selectedNews = ref(null)
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
const modalTitleId = 'public-news-modal-title'
|
||||||
|
|
||||||
const loadNews = async () => {
|
const loadNews = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -164,19 +173,30 @@ const getGridClass = () => {
|
|||||||
|
|
||||||
const openNewsModal = (item) => {
|
const openNewsModal = (item) => {
|
||||||
selectedNews.value = item
|
selectedNews.value = item
|
||||||
// Verhindere Scrollen im Hintergrund
|
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.addEventListener('keydown', handleModalKeydown)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeNewsModal = () => {
|
const closeNewsModal = () => {
|
||||||
selectedNews.value = null
|
selectedNews.value = null
|
||||||
// Erlaube Scrollen wieder
|
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
|
document.removeEventListener('keydown', handleModalKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalKeydown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeNewsModal()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadNews()
|
loadNews()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
document.removeEventListener('keydown', handleModalKeydown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -186,5 +206,20 @@ onMounted(() => {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.news-modal-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-modal-enter-active,
|
||||||
|
.news-modal-leave-active {
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-modal-enter-from,
|
||||||
|
.news-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -279,9 +279,25 @@ use_project_node
|
|||||||
ensure_node_version
|
ensure_node_version
|
||||||
install_dependencies_if_needed
|
install_dependencies_if_needed
|
||||||
|
|
||||||
# 4. Remove old build (but keep data!)
|
# 4. Stop running apps before replacing build artifacts
|
||||||
echo ""
|
echo ""
|
||||||
echo "4. Cleaning build artifacts..."
|
echo "4. Stopping PM2 before replacing build artifacts..."
|
||||||
|
if command -v pm2 >/dev/null 2>&1; then
|
||||||
|
for instance_name in harheimertc harheimertc-3102; do
|
||||||
|
if pm2 describe "$instance_name" >/dev/null 2>&1; then
|
||||||
|
pm2 stop "$instance_name" || true
|
||||||
|
echo " ✓ $instance_name gestoppt"
|
||||||
|
else
|
||||||
|
echo " PM2-Prozess $instance_name läuft nicht"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo " PM2 ist nicht verfügbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Remove old build (but keep data!)
|
||||||
|
echo ""
|
||||||
|
echo "5. Cleaning build artifacts..."
|
||||||
# Sicherstellen, dass .output vollständig gelöscht wird
|
# Sicherstellen, dass .output vollständig gelöscht wird
|
||||||
if [ -d ".output" ]; then
|
if [ -d ".output" ]; then
|
||||||
echo " Removing .output directory..."
|
echo " Removing .output directory..."
|
||||||
|
|||||||
@@ -285,9 +285,19 @@ use_project_node
|
|||||||
ensure_node_version
|
ensure_node_version
|
||||||
install_dependencies_if_needed
|
install_dependencies_if_needed
|
||||||
|
|
||||||
# 4. Remove old build (but keep data!)
|
# 4. Stop running app before replacing build artifacts
|
||||||
echo ""
|
echo ""
|
||||||
echo "4. Cleaning build artifacts..."
|
echo "4. Stopping PM2 before replacing build artifacts..."
|
||||||
|
if command -v pm2 >/dev/null 2>&1 && pm2 describe harheimertc.test >/dev/null 2>&1; then
|
||||||
|
pm2 stop harheimertc.test || true
|
||||||
|
echo " ✓ harheimertc.test gestoppt"
|
||||||
|
else
|
||||||
|
echo " PM2-Prozess harheimertc.test läuft nicht oder PM2 ist nicht verfügbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Remove old build (but keep data!)
|
||||||
|
echo ""
|
||||||
|
echo "5. Cleaning build artifacts..."
|
||||||
# Sicherstellen, dass .output vollständig gelöscht wird
|
# Sicherstellen, dass .output vollständig gelöscht wird
|
||||||
if [ -d ".output" ]; then
|
if [ -d ".output" ]; then
|
||||||
echo " Removing .output directory..."
|
echo " Removing .output directory..."
|
||||||
|
|||||||
67
google-services.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "174247719758",
|
||||||
|
"project_id": "harheimer-tc",
|
||||||
|
"storage_bucket": "harheimer-tc.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:174247719758:android:04240f5a6ecc06b8eba41f",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "de.harheimertc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:174247719758:android:852797b868a6413feba41f",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "de.harheimertc.local"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:174247719758:android:0663d7ca236b5db2eba41f",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "de.harheimertc.test"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create env object
|
// Helper function to create env object
|
||||||
|
const DEFAULT_GOOGLE_APPLICATION_CREDENTIALS = '/var/www/harheimertc/server/data/harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
|
||||||
|
|
||||||
function createEnv(port) {
|
function createEnv(port) {
|
||||||
return {
|
return {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
@@ -35,7 +37,10 @@ function createEnv(port) {
|
|||||||
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
|
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
|
||||||
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
|
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
|
||||||
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
|
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
|
||||||
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV
|
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV,
|
||||||
|
FCM_SERVICE_ACCOUNT_JSON: process.env.FCM_SERVICE_ACCOUNT_JSON,
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS || DEFAULT_GOOGLE_APPLICATION_CREDENTIALS,
|
||||||
|
FCM_PROJECT_ID: process.env.FCM_PROJECT_ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create env object
|
// Helper function to create env object
|
||||||
|
const DEFAULT_GOOGLE_APPLICATION_CREDENTIALS = '/var/www/harheimertc.test/server/data/harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
|
||||||
|
|
||||||
function createEnv(port) {
|
function createEnv(port) {
|
||||||
return {
|
return {
|
||||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
@@ -37,7 +39,10 @@ function createEnv(port) {
|
|||||||
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
|
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
|
||||||
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
|
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
|
||||||
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
|
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
|
||||||
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV
|
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV,
|
||||||
|
FCM_SERVICE_ACCOUNT_JSON: process.env.FCM_SERVICE_ACCOUNT_JSON,
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS || DEFAULT_GOOGLE_APPLICATION_CREDENTIALS,
|
||||||
|
FCM_PROJECT_ID: process.env.FCM_PROJECT_ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.7.0",
|
"version": "1.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.7.0",
|
"version": "1.8.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"postcss": "^8.5.12",
|
"postcss": "^8.5.12",
|
||||||
"supertest": "^7.1.0",
|
"supertest": "^7.1.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.1.8",
|
||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5418,31 +5418,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||||
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
|
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "4.0.16",
|
"@vitest/spy": "4.1.8",
|
||||||
"@vitest/utils": "4.0.16",
|
"@vitest/utils": "4.1.8",
|
||||||
"chai": "^6.2.1",
|
"chai": "^6.2.2",
|
||||||
"tinyrainbow": "^3.0.3"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||||
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
|
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.0.16",
|
"@vitest/spy": "4.1.8",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
@@ -5451,7 +5451,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"msw": "^2.4.9",
|
"msw": "^2.4.9",
|
||||||
"vite": "^6.0.0 || ^7.0.0-0"
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"msw": {
|
"msw": {
|
||||||
@@ -5463,26 +5463,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||||
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
|
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tinyrainbow": "^3.0.3"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||||
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
|
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.0.16",
|
"@vitest/utils": "4.1.8",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -5490,13 +5490,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||||
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
|
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.0.16",
|
"@vitest/pretty-format": "4.1.8",
|
||||||
|
"@vitest/utils": "4.1.8",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -5505,9 +5506,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||||
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==",
|
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -5515,14 +5516,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||||
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
|
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.0.16",
|
"@vitest/pretty-format": "4.1.8",
|
||||||
"tinyrainbow": "^3.0.3"
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
@@ -6666,9 +6668,9 @@
|
|||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
"integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==",
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7733,10 +7735,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "1.7.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
@@ -8164,9 +8165,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.2.2",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
"integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9014,12 +9015,6 @@
|
|||||||
"unplugin-utils": "^0.3.1"
|
"unplugin-utils": "^0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/impound/node_modules/es-module-lexer": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/impound/node_modules/unplugin": {
|
"node_modules/impound/node_modules/unplugin": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
|
||||||
@@ -12985,9 +12980,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.3",
|
"version": "1.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -13961,9 +13956,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyrainbow": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "3.0.3",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14624,12 +14619,6 @@
|
|||||||
"url": "https://opencollective.com/antfu"
|
"url": "https://opencollective.com/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite-node/node_modules/es-module-lexer": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vite-plugin-inspect": {
|
"node_modules/vite-plugin-inspect": {
|
||||||
"version": "11.3.3",
|
"version": "11.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz",
|
||||||
@@ -15172,31 +15161,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.0.16",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.16",
|
"@vitest/expect": "4.1.8",
|
||||||
"@vitest/mocker": "4.0.16",
|
"@vitest/mocker": "4.1.8",
|
||||||
"@vitest/pretty-format": "4.0.16",
|
"@vitest/pretty-format": "4.1.8",
|
||||||
"@vitest/runner": "4.0.16",
|
"@vitest/runner": "4.1.8",
|
||||||
"@vitest/snapshot": "4.0.16",
|
"@vitest/snapshot": "4.1.8",
|
||||||
"@vitest/spy": "4.0.16",
|
"@vitest/spy": "4.1.8",
|
||||||
"@vitest/utils": "4.0.16",
|
"@vitest/utils": "4.1.8",
|
||||||
"es-module-lexer": "^1.7.0",
|
"es-module-lexer": "^2.0.0",
|
||||||
"expect-type": "^1.2.2",
|
"expect-type": "^1.3.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"obug": "^2.1.1",
|
"obug": "^2.1.1",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"std-env": "^3.10.0",
|
"std-env": "^4.0.0-rc.1",
|
||||||
"tinybench": "^2.9.0",
|
"tinybench": "^2.9.0",
|
||||||
"tinyexec": "^1.0.2",
|
"tinyexec": "^1.0.2",
|
||||||
"tinyglobby": "^0.2.15",
|
"tinyglobby": "^0.2.15",
|
||||||
"tinyrainbow": "^3.0.3",
|
"tinyrainbow": "^3.1.0",
|
||||||
"vite": "^6.0.0 || ^7.0.0",
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
"why-is-node-running": "^2.3.0"
|
"why-is-node-running": "^2.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -15212,12 +15201,15 @@
|
|||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser-playwright": "4.0.16",
|
"@vitest/browser-playwright": "4.1.8",
|
||||||
"@vitest/browser-preview": "4.0.16",
|
"@vitest/browser-preview": "4.1.8",
|
||||||
"@vitest/browser-webdriverio": "4.0.16",
|
"@vitest/browser-webdriverio": "4.1.8",
|
||||||
"@vitest/ui": "4.0.16",
|
"@vitest/coverage-istanbul": "4.1.8",
|
||||||
|
"@vitest/coverage-v8": "4.1.8",
|
||||||
|
"@vitest/ui": "4.1.8",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*"
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edge-runtime/vm": {
|
"@edge-runtime/vm": {
|
||||||
@@ -15238,6 +15230,12 @@
|
|||||||
"@vitest/browser-webdriverio": {
|
"@vitest/browser-webdriverio": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"@vitest/ui": {
|
"@vitest/ui": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -15246,9 +15244,19 @@
|
|||||||
},
|
},
|
||||||
"jsdom": {
|
"jsdom": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vscode-uri": {
|
"node_modules/vscode-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.8.0",
|
"version": "1.8.1",
|
||||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -16,9 +16,12 @@
|
|||||||
"start": "nuxt start --port 3100",
|
"start": "nuxt start --port 3100",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
|
||||||
"check-security": "node scripts/verify-no-public-writes.js",
|
"check-security": "node scripts/verify-no-public-writes.js",
|
||||||
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
||||||
"sync-public-data": "node scripts/sync-public-data.js",
|
"sync-public-data": "node scripts/sync-public-data.js",
|
||||||
|
"data-backups:list": "node scripts/data-backup-restore.js list",
|
||||||
|
"data-backups:restore": "node scripts/data-backup-restore.js restore",
|
||||||
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
|
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
|
||||||
"import-spielplan": "node scripts/import-spielplan.js",
|
"import-spielplan": "node scripts/import-spielplan.js",
|
||||||
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
||||||
@@ -43,7 +46,6 @@
|
|||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"quill": "^2.0.2",
|
"quill": "^2.0.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -58,7 +60,7 @@
|
|||||||
"postcss": "^8.5.12",
|
"postcss": "^8.5.12",
|
||||||
"supertest": "^7.1.0",
|
"supertest": "^7.1.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.1.8",
|
||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
Passwort zurücksetzen
|
Passwort zurücksetzen
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen
|
Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
125
pages/passwort-zuruecksetzen.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full flex items-center justify-center py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-3xl font-display font-bold text-gray-900">
|
||||||
|
Neues Passwort setzen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
|
Vergeben Sie ein neues Passwort für Ihren Zugang.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8">
|
||||||
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
|
<div v-if="!token" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-red-800">Der Reset-Link ist unvollständig. Fordern Sie bitte einen neuen Link an.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Neues Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="passwordRepeat" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passwordRepeat"
|
||||||
|
v-model="passwordRepeat"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-red-800">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-green-800 flex items-center">
|
||||||
|
<Check :size="18" class="mr-2" />
|
||||||
|
{{ successMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading || !token || Boolean(successMessage)"
|
||||||
|
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
|
||||||
|
<span>{{ isLoading ? 'Wird gespeichert...' : 'Passwort speichern' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
Zurück zum Login
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Check, Loader2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const token = computed(() => String(route.query.token || '').trim())
|
||||||
|
const password = ref('')
|
||||||
|
const passwordRepeat = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
errorMessage.value = 'Das Passwort muss mindestens 8 Zeichen lang sein.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value !== passwordRepeat.value) {
|
||||||
|
errorMessage.value = 'Die Passwörter stimmen nicht überein.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/reset-password/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { token: token.value, password: password.value }
|
||||||
|
})
|
||||||
|
successMessage.value = response?.message || 'Ihr Passwort wurde geändert.'
|
||||||
|
password.value = ''
|
||||||
|
passwordRepeat.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || 'Der Reset-Link ist ungültig oder abgelaufen.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Neues Passwort setzen - Harheimer TC',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
25
plugins/auth-sync.client.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const syncAuthState = async () => {
|
||||||
|
await authStore.checkAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxtApp.hook('app:mounted', () => {
|
||||||
|
syncAuthState()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('pageshow', (event) => {
|
||||||
|
if (event.persisted) {
|
||||||
|
syncAuthState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
syncAuthState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
165
scripts/data-backup-restore.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import path from 'path'
|
||||||
|
import {
|
||||||
|
getBackupDirectoryForDataFile,
|
||||||
|
listDataFileBackups,
|
||||||
|
restoreDataFileBackup
|
||||||
|
} from '../server/utils/data-file-rotation.js'
|
||||||
|
|
||||||
|
const FILES = {
|
||||||
|
'users.json': getDataPath('users.json'),
|
||||||
|
'sessions.json': getDataPath('sessions.json'),
|
||||||
|
'members.json': getDataPath('members.json'),
|
||||||
|
'newsletter-subscribers.json': getDataPath('newsletter-subscribers.json'),
|
||||||
|
'news.json': getDataPath('news.json'),
|
||||||
|
'termine.csv': getDataPath('termine.csv'),
|
||||||
|
'contact-requests.json': getDataPath('contact-requests.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataPath(filename) {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArg(name) {
|
||||||
|
const index = process.argv.findIndex((arg) => arg === name)
|
||||||
|
if (index === -1) return null
|
||||||
|
const next = process.argv[index + 1]
|
||||||
|
if (!next || next.startsWith('--')) return null
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(name) {
|
||||||
|
return process.argv.includes(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log('Verwendung:')
|
||||||
|
console.log(' node scripts/data-backup-restore.js list [--file users.json]')
|
||||||
|
console.log(' node scripts/data-backup-restore.js restore --file users.json --latest')
|
||||||
|
console.log(' node scripts/data-backup-restore.js restore --file users.json --backup <backup-datei.bak>')
|
||||||
|
console.log('')
|
||||||
|
console.log('Optionen:')
|
||||||
|
console.log(' --file Eine der bekannten Daten-Dateien')
|
||||||
|
console.log(' --latest Stellt das neueste Backup wieder her')
|
||||||
|
console.log(' --backup Konkreter Backup-Dateiname (*.bak)')
|
||||||
|
console.log('')
|
||||||
|
console.log('Bekannte Dateien:')
|
||||||
|
Object.keys(FILES).forEach((name) => console.log(` - ${name}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCommand() {
|
||||||
|
const requestedFile = parseArg('--file')
|
||||||
|
const names = requestedFile ? [requestedFile] : Object.keys(FILES)
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const dataPath = FILES[name]
|
||||||
|
if (!dataPath) {
|
||||||
|
console.error(`Unbekannte Datei: ${name}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = await listDataFileBackups(dataPath)
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(dataPath)
|
||||||
|
|
||||||
|
console.log(`\n${name}`)
|
||||||
|
console.log(` Datenpfad: ${dataPath}`)
|
||||||
|
console.log(` Backup-Ordner: ${backupDir}`)
|
||||||
|
|
||||||
|
if (backups.length === 0) {
|
||||||
|
console.log(' Backups: keine')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Backups (${backups.length}, neuestes zuerst):`)
|
||||||
|
backups.slice(0, 15).forEach((backup) => {
|
||||||
|
console.log(` - ${backup}`)
|
||||||
|
})
|
||||||
|
if (backups.length > 15) {
|
||||||
|
console.log(` ... (${backups.length - 15} weitere)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCommand() {
|
||||||
|
const fileName = parseArg('--file')
|
||||||
|
if (!fileName) {
|
||||||
|
console.error('Fehlend: --file <datei>')
|
||||||
|
printUsage()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataPath = FILES[fileName]
|
||||||
|
if (!dataPath) {
|
||||||
|
console.error(`Unbekannte Datei: ${fileName}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = await listDataFileBackups(dataPath)
|
||||||
|
if (backups.length === 0) {
|
||||||
|
console.error(`Keine Backups gefunden für ${fileName}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupName = parseArg('--backup')
|
||||||
|
const latest = hasFlag('--latest')
|
||||||
|
|
||||||
|
let targetBackup = backupName
|
||||||
|
if (!targetBackup && latest) {
|
||||||
|
targetBackup = backups[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetBackup) {
|
||||||
|
console.error('Bitte --latest oder --backup <name> angeben')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backups.includes(targetBackup)) {
|
||||||
|
console.error(`Backup nicht gefunden: ${targetBackup}`)
|
||||||
|
console.error('Nutzen Sie zuerst: node scripts/data-backup-restore.js list --file <datei>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await restoreDataFileBackup(dataPath, targetBackup)
|
||||||
|
|
||||||
|
console.log(`Wiederherstellung abgeschlossen: ${fileName}`)
|
||||||
|
console.log(` Eingespieltes Backup: ${targetBackup}`)
|
||||||
|
if (result.backupPath) {
|
||||||
|
console.log(` Backup des vorherigen Zustands: ${result.backupPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const command = process.argv[2]
|
||||||
|
|
||||||
|
if (!command || command === '--help' || command === '-h') {
|
||||||
|
printUsage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'list') {
|
||||||
|
await listCommand()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'restore') {
|
||||||
|
await restoreCommand()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Unbekannter Befehl: ${command}`)
|
||||||
|
printUsage()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Fehler im Backup/Restore-Skript:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -1,10 +1,45 @@
|
|||||||
import { readUsers, hashPassword, writeUsers, revokeRefreshSessionsForUser } from '../../utils/auth.js'
|
import { readUsers, writeUsers } from '../../utils/auth.js'
|
||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
||||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||||
import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js'
|
import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js'
|
||||||
|
|
||||||
|
const RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TTL_MIN || 60)
|
||||||
|
const RESET_TOKEN_MAX_AGE_MS = RESET_TOKEN_TTL_MINUTES * 60 * 1000
|
||||||
|
|
||||||
|
function generateResetToken() {
|
||||||
|
return crypto.randomBytes(32).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashResetToken(token) {
|
||||||
|
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResetBaseUrl(event) {
|
||||||
|
const configured = process.env.NUXT_PUBLIC_BASE_URL
|
||||||
|
if (configured) return configured.replace(/\/$/, '')
|
||||||
|
|
||||||
|
const requestUrl = getRequestURL(event)
|
||||||
|
return `${requestUrl.protocol}//${requestUrl.host}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function prunePasswordResetTokens(user) {
|
||||||
|
const now = Date.now()
|
||||||
|
user.passwordResetTokens = (Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : [])
|
||||||
|
.filter(token => !token.usedAt && new Date(token.expiresAt).getTime() > now)
|
||||||
|
.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID()
|
||||||
let emailKey = ''
|
let emailKey = ''
|
||||||
@@ -34,7 +69,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate Limiting (IP + Account)
|
|
||||||
await logStep('rate_limit', 'checking')
|
await logStep('rate_limit', 'checking')
|
||||||
try {
|
try {
|
||||||
assertRateLimit(event, {
|
assertRateLimit(event, {
|
||||||
@@ -57,7 +91,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
await logStep('rate_limit', 'passed')
|
await logStep('rate_limit', 'passed')
|
||||||
|
|
||||||
// Find user
|
|
||||||
let users
|
let users
|
||||||
try {
|
try {
|
||||||
users = await readUsers()
|
users = await readUsers()
|
||||||
@@ -67,7 +100,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
const user = users.find(u => normalizeResetEmail(u.email) === emailKey)
|
const user = users.find(u => normalizeResetEmail(u.email) === emailKey)
|
||||||
|
|
||||||
// Always return success (security: don't reveal if email exists)
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await logStep('user_lookup', 'not_found')
|
await logStep('user_lookup', 'not_found')
|
||||||
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
|
await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] })
|
||||||
@@ -81,82 +113,84 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
await logStep('user_lookup', 'found', { userId: user.id })
|
await logStep('user_lookup', 'found', { userId: user.id })
|
||||||
|
|
||||||
// Generate temporary password
|
|
||||||
const tempPassword = crypto.randomBytes(8).toString('hex')
|
|
||||||
const hashedPassword = await hashPassword(tempPassword)
|
|
||||||
await logStep('temporary_password', 'generated', { userId: user.id })
|
|
||||||
|
|
||||||
// Send email with temporary password
|
|
||||||
const smtpUser = process.env.SMTP_USER
|
const smtpUser = process.env.SMTP_USER
|
||||||
const smtpPass = process.env.SMTP_PASS
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
if (!smtpUser || !smtpPass) {
|
if (!smtpUser || !smtpPass) {
|
||||||
await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' })
|
await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' })
|
||||||
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
|
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
|
||||||
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
||||||
throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt')
|
throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt')
|
||||||
} else {
|
|
||||||
await logStep('mail_configuration', 'passed', { userId: user.id })
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
|
||||||
port: process.env.SMTP_PORT || 587,
|
|
||||||
secure: false,
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const mailOptions = {
|
|
||||||
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
|
||||||
to: user.email,
|
|
||||||
subject: 'Passwort zurücksetzen - Harheimer TC',
|
|
||||||
html: `
|
|
||||||
<h2>Passwort zurücksetzen</h2>
|
|
||||||
<p>Hallo ${user.name},</p>
|
|
||||||
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
|
|
||||||
<p>Ihr temporäres Passwort lautet: <strong>${tempPassword}</strong></p>
|
|
||||||
<p>Bitte melden Sie sich damit an und ändern Sie Ihr Passwort im Mitgliederbereich.</p>
|
|
||||||
<br>
|
|
||||||
<p>Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
|
|
||||||
<br>
|
|
||||||
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
await logStep('mail_send', 'started', { userId: user.id })
|
|
||||||
try {
|
|
||||||
await transporter.sendMail(mailOptions)
|
|
||||||
} catch (error) {
|
|
||||||
await logStep('mail_send', 'failed', { userId: user.id, error })
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
await logStep('mail_send', 'completed', { userId: user.id })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erst nach erfolgreichem Versand das zugesandte Passwort aktivieren.
|
await logStep('mail_configuration', 'passed', { userId: user.id })
|
||||||
user.password = hashedPassword
|
|
||||||
user.passwordResetRequired = true
|
const token = generateResetToken()
|
||||||
|
const tokenHash = hashResetToken(token)
|
||||||
|
const nowIso = new Date().toISOString()
|
||||||
|
const expiresAt = new Date(Date.now() + RESET_TOKEN_MAX_AGE_MS).toISOString()
|
||||||
|
prunePasswordResetTokens(user)
|
||||||
|
user.passwordResetTokens.push({
|
||||||
|
tokenHash,
|
||||||
|
createdAt: nowIso,
|
||||||
|
expiresAt,
|
||||||
|
usedAt: null
|
||||||
|
})
|
||||||
|
await logStep('reset_token', 'generated', { userId: user.id, expiresAt })
|
||||||
|
|
||||||
const updatedUsers = users.map(u => u.id === user.id ? user : u)
|
const updatedUsers = users.map(u => u.id === user.id ? user : u)
|
||||||
let passwordStored = false
|
let tokenStored = false
|
||||||
try {
|
try {
|
||||||
passwordStored = await writeUsers(updatedUsers)
|
tokenStored = await writeUsers(updatedUsers)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logStep('password_storage', 'failed', { userId: user.id, error })
|
await logStep('token_storage', 'failed', { userId: user.id, error })
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
if (!passwordStored) {
|
if (!tokenStored) {
|
||||||
await logStep('password_storage', 'failed', { userId: user.id, reason: 'write_failed' })
|
await logStep('token_storage', 'failed', { userId: user.id, reason: 'write_failed' })
|
||||||
throw new Error('Passwort konnte nach E-Mail-Versand nicht gespeichert werden')
|
throw new Error('Reset-Token konnte nicht gespeichert werden')
|
||||||
}
|
}
|
||||||
await logStep('password_storage', 'completed', { userId: user.id })
|
await logStep('token_storage', 'completed', { userId: user.id })
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: process.env.SMTP_PORT || 587,
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPass
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetUrl = `${getResetBaseUrl(event)}/passwort-zuruecksetzen?token=${encodeURIComponent(token)}`
|
||||||
|
const displayName = escapeHtml(user.name || 'Mitglied')
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
||||||
|
to: user.email,
|
||||||
|
subject: 'Passwort zurücksetzen - Harheimer TC',
|
||||||
|
html: `
|
||||||
|
<h2>Passwort zurücksetzen</h2>
|
||||||
|
<p>Hallo ${displayName},</p>
|
||||||
|
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
|
||||||
|
<p>Bitte klicken Sie auf den folgenden Link und vergeben Sie dort ein neues Passwort. Der Link ist ${RESET_TOKEN_TTL_MINUTES} Minuten gültig:</p>
|
||||||
|
<p><a href="${resetUrl}">Neues Passwort setzen</a></p>
|
||||||
|
<p>Ihr bisheriges Passwort bleibt gültig, bis Sie über diesen Link ein neues Passwort gesetzt haben.</p>
|
||||||
|
<br>
|
||||||
|
<p>Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
|
||||||
|
<br>
|
||||||
|
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStep('mail_send', 'started', { userId: user.id })
|
||||||
try {
|
try {
|
||||||
await revokeRefreshSessionsForUser(user.id, 'password_reset')
|
await transporter.sendMail(mailOptions)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logStep('session_revocation', 'failed', { userId: user.id, error })
|
await logStep('mail_send', 'failed', { userId: user.id, error })
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
await logStep('session_revocation', 'completed', { userId: user.id })
|
await logStep('mail_send', 'completed', { userId: user.id })
|
||||||
|
|
||||||
registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
|
registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] })
|
||||||
await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId })
|
await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId })
|
||||||
@@ -168,7 +202,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logStep('request_completed', 'failed', { error })
|
await logStep('request_completed', 'failed', { error })
|
||||||
console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' })
|
console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' })
|
||||||
// Don't reveal errors to prevent email enumeration
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
|
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
|
||||||
|
|||||||
82
server/api/auth/reset-password/complete.post.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import { hashPassword, readUsers, revokeRefreshSessionsForUser, writeUsers } from '../../../utils/auth.js'
|
||||||
|
import { getClientIp } from '../../../utils/rate-limit.js'
|
||||||
|
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||||
|
import { writePasswordResetLog } from '../../../utils/password-reset-log.js'
|
||||||
|
|
||||||
|
function hashResetToken(token) {
|
||||||
|
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrongEnoughPassword(password) {
|
||||||
|
return typeof password === 'string' && password.length >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
const ip = getClientIp(event)
|
||||||
|
const body = await readBody(event)
|
||||||
|
const token = String(body?.token || '').trim()
|
||||||
|
const password = String(body?.password || '')
|
||||||
|
|
||||||
|
const logStep = async (step, status, detail = {}) => {
|
||||||
|
try {
|
||||||
|
await writePasswordResetLog({ requestId, email: detail.email || '', ip, step, status, ...detail })
|
||||||
|
} catch (logError) {
|
||||||
|
console.error('Password-Reset-Diagnoselog-Fehler:', logError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
await logStep('complete_validation', 'failed', { reason: 'token_missing' })
|
||||||
|
throw createError({ statusCode: 400, message: 'Reset-Link fehlt.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStrongEnoughPassword(password)) {
|
||||||
|
await logStep('complete_validation', 'failed', { reason: 'password_too_short' })
|
||||||
|
throw createError({ statusCode: 400, message: 'Das Passwort muss mindestens 8 Zeichen lang sein.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await readUsers()
|
||||||
|
const tokenHash = hashResetToken(token)
|
||||||
|
const now = Date.now()
|
||||||
|
let matchedUser = null
|
||||||
|
let matchedToken = null
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const tokens = Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : []
|
||||||
|
const candidate = tokens.find(entry => entry.tokenHash === tokenHash)
|
||||||
|
if (candidate) {
|
||||||
|
matchedUser = user
|
||||||
|
matchedToken = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedUser || !matchedToken || matchedToken.usedAt || new Date(matchedToken.expiresAt).getTime() <= now) {
|
||||||
|
await logStep('complete_token', 'failed', { reason: 'invalid_or_expired' })
|
||||||
|
throw createError({ statusCode: 400, message: 'Der Reset-Link ist ungültig oder abgelaufen.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString()
|
||||||
|
matchedUser.password = await hashPassword(password)
|
||||||
|
matchedUser.passwordResetRequired = false
|
||||||
|
matchedToken.usedAt = nowIso
|
||||||
|
matchedUser.passwordResetTokens = (Array.isArray(matchedUser.passwordResetTokens) ? matchedUser.passwordResetTokens : [])
|
||||||
|
.filter(entry => entry.usedAt || new Date(entry.expiresAt).getTime() > now)
|
||||||
|
|
||||||
|
const stored = await writeUsers(users)
|
||||||
|
if (!stored) {
|
||||||
|
await logStep('complete_password_storage', 'failed', { userId: matchedUser.id, email: matchedUser.email, reason: 'write_failed' })
|
||||||
|
throw createError({ statusCode: 500, message: 'Das neue Passwort konnte nicht gespeichert werden.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await revokeRefreshSessionsForUser(matchedUser.id, 'password_reset_completed')
|
||||||
|
await writeAuditLog('auth.reset.complete', { ip, userId: matchedUser.id, requestId })
|
||||||
|
await logStep('complete_password_storage', 'completed', { userId: matchedUser.id, email: matchedUser.email })
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Ihr Passwort wurde geändert. Sie können sich jetzt mit dem neuen Passwort anmelden.'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readMembers } from '../utils/members.js'
|
import { readMembers } from '../utils/members.js'
|
||||||
import { readUsers, getUserFromToken, verifyToken } from '../utils/auth.js'
|
import { readUsers, getUserFromToken, verifyToken, isHiddenUser, normalizeUserEmail } from '../utils/auth.js'
|
||||||
|
|
||||||
// Helper: returns array of upcoming birthdays within daysAhead (inclusive)
|
// Helper: returns array of upcoming birthdays within daysAhead (inclusive)
|
||||||
function getUpcomingBirthdays(entries, daysAhead = 28) {
|
function getUpcomingBirthdays(entries, daysAhead = 28) {
|
||||||
@@ -53,10 +53,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
const manualMembers = await readMembers()
|
const manualMembers = await readMembers()
|
||||||
const registeredUsers = await readUsers()
|
const registeredUsers = await readUsers()
|
||||||
|
|
||||||
|
const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
|
||||||
|
|
||||||
// Build unified list of candidates with geburtsdatum and visibility
|
// Build unified list of candidates with geburtsdatum and visibility
|
||||||
const candidates = []
|
const candidates = []
|
||||||
|
|
||||||
for (const m of manualMembers) {
|
for (const m of manualMembers) {
|
||||||
|
const memberEmail = normalizeUserEmail(m.email)
|
||||||
|
if (m.hidden === true || m.invisible === true || m.isHidden === true || hiddenUserEmails.has(memberEmail)) continue
|
||||||
const normalizedStatus = m.status ? String(m.status).toLowerCase() : ''
|
const normalizedStatus = m.status ? String(m.status).toLowerCase() : ''
|
||||||
const hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== ''
|
const hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== ''
|
||||||
const isAccepted = hasExplicitAcceptanceFlag
|
const isAccepted = hasExplicitAcceptanceFlag
|
||||||
@@ -73,7 +77,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const u of registeredUsers) {
|
for (const u of registeredUsers) {
|
||||||
if (!u.active) continue
|
if (!u.active || isHiddenUser(u)) continue
|
||||||
const vis = u.visibility || {}
|
const vis = u.visibility || {}
|
||||||
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
|
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
|
||||||
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
|
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getUserFromToken, hasRole, readUsers } from '../../utils/auth.js'
|
import { getUserFromToken, hasRole, readUsers, isHiddenUser } from '../../utils/auth.js'
|
||||||
import {
|
import {
|
||||||
fingerprintResetEmail,
|
fingerprintResetEmail,
|
||||||
normalizeResetEmail,
|
normalizeResetEmail,
|
||||||
@@ -59,17 +59,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
const email = normalizeResetEmail(query.email)
|
const email = normalizeResetEmail(query.email)
|
||||||
const failedOnly = query.failedOnly !== 'false'
|
const failedOnly = query.failedOnly !== 'false'
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
|
const visibleUsers = users.filter(user => !isHiddenUser(user))
|
||||||
|
const hiddenEmailFingerprints = new Set(users.filter(isHiddenUser).map(user => fingerprintResetEmail(user.email)).filter(Boolean))
|
||||||
const logs = await readPasswordResetLogs()
|
const logs = await readPasswordResetLogs()
|
||||||
const filteredLogs = email
|
const filteredLogs = (email
|
||||||
? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email))
|
? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email))
|
||||||
: logs
|
: logs)
|
||||||
|
.filter(entry => !hiddenEmailFingerprints.has(entry.emailFingerprint))
|
||||||
const attempts = summarizeAttempts(filteredLogs)
|
const attempts = summarizeAttempts(filteredLogs)
|
||||||
.filter(attempt => !failedOnly || attempt.failed)
|
.filter(attempt => !failedOnly || attempt.failed)
|
||||||
|
|
||||||
let matchingUsers = []
|
let matchingUsers = []
|
||||||
if (email) {
|
if (email) {
|
||||||
const term = email.toLowerCase()
|
const term = email.toLowerCase()
|
||||||
matchingUsers = users
|
matchingUsers = visibleUsers
|
||||||
.filter(user => {
|
.filter(user => {
|
||||||
const userEmail = normalizeResetEmail(user.email)
|
const userEmail = normalizeResetEmail(user.email)
|
||||||
const name = String(user.name || '').toLowerCase()
|
const name = String(user.name || '').toLowerCase()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles, isHiddenUser } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
|
// Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
|
||||||
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
|
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
|
||||||
|
|
||||||
const safeUsers = users.map(u => {
|
const safeUsers = users.filter(u => !isHiddenUser(u)).map(u => {
|
||||||
const migrated = migrateUserRoles({ ...u })
|
const migrated = migrateUserRoles({ ...u })
|
||||||
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
|
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import nodemailer from 'nodemailer'
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { createContactRequest } from '../utils/contact-requests.js'
|
import { createContactRequest } from '../utils/contact-requests.js'
|
||||||
import { readUsers, migrateUserRoles } from '../utils/auth.js'
|
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant ('config.json'), never user input
|
// filename is always a hardcoded constant ('config.json'), never user input
|
||||||
@@ -54,6 +54,7 @@ async function collectRecipients(config) {
|
|||||||
try {
|
try {
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
for (const rawUser of users) {
|
for (const rawUser of users) {
|
||||||
|
if (isHiddenUser(rawUser)) continue
|
||||||
const user = migrateUserRoles({ ...rawUser })
|
const user = migrateUserRoles({ ...rawUser })
|
||||||
const roles = Array.isArray(user.roles) ? user.roles : []
|
const roles = Array.isArray(user.roles) ? user.roles : []
|
||||||
if (roles.includes('trainer') && user.email && String(user.email).trim()) {
|
if (roles.includes('trainer') && user.email && String(user.email).trim()) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { verifyToken, getUserFromToken, hasRole } from '../utils/auth.js'
|
import { verifyToken, getUserFromToken, hasRole } from '../utils/auth.js'
|
||||||
import { readMembers } from '../utils/members.js'
|
import { readMembers } from '../utils/members.js'
|
||||||
import { readUsers, migrateUserRoles } from '../utils/auth.js'
|
import { readUsers, migrateUserRoles, isHiddenUser, normalizeUserEmail } from '../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -52,7 +52,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Skip applications that are not yet accepted
|
// Skip applications that are not yet accepted
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const normalizedEmail = member.email?.toLowerCase().trim() || ''
|
const normalizedEmail = normalizeUserEmail(member.email)
|
||||||
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim()
|
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim()
|
||||||
const normalizedName = fullName.toLowerCase()
|
const normalizedName = fullName.toLowerCase()
|
||||||
|
|
||||||
@@ -90,11 +90,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
|
||||||
|
|
||||||
// Then add registered users (only active ones)
|
// Then add registered users (only active ones)
|
||||||
for (const user of registeredUsers) {
|
for (const user of registeredUsers) {
|
||||||
if (!user.active) continue
|
if (!user.active || isHiddenUser(user)) continue
|
||||||
|
|
||||||
const normalizedEmail = user.email?.toLowerCase().trim() || ''
|
const normalizedEmail = normalizeUserEmail(user.email)
|
||||||
const normalizedName = user.name?.toLowerCase().trim() || ''
|
const normalizedName = user.name?.toLowerCase().trim() || ''
|
||||||
|
|
||||||
// Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name
|
// Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name
|
||||||
@@ -208,7 +210,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
|
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
|
||||||
|
|
||||||
// Filtere den Admin-Account heraus
|
// Filtere den Admin-Account heraus
|
||||||
const filteredMembers = mergedMembers.filter(m => m.email?.toLowerCase() !== 'admin@harheimertc.de')
|
const filteredMembers = mergedMembers.filter(m => {
|
||||||
|
const email = normalizeUserEmail(m.email || m.loginEmail)
|
||||||
|
return email !== 'admin@harheimertc.de' && !hiddenUserEmails.has(email) && !m.hidden && !m.invisible && !m.isHidden
|
||||||
|
})
|
||||||
const sanitizedMembers = filteredMembers.map(member => {
|
const sanitizedMembers = filteredMembers.map(member => {
|
||||||
// Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them
|
// Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them
|
||||||
const visibility = member.visibility || {}
|
const visibility = member.visibility || {}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import { getServerDataPath } from '../../utils/paths.js'
|
import { getServerDataPath } from '../../utils/paths.js'
|
||||||
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
import { getUserFromToken, verifyToken, readUsers, isHiddenUser, normalizeUserEmail } from '../../utils/auth.js'
|
||||||
import { readMembers } from '../../utils/members.js'
|
import { readMembers } from '../../utils/members.js'
|
||||||
import { readUsers } from '../../utils/auth.js'
|
|
||||||
|
|
||||||
const QTTR_FILE = getServerDataPath('qttr-values.json')
|
const QTTR_FILE = getServerDataPath('qttr-values.json')
|
||||||
|
|
||||||
@@ -62,15 +61,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
readMembers(),
|
readMembers(),
|
||||||
readUsers()
|
readUsers()
|
||||||
])
|
])
|
||||||
const birthdateLookup = buildBirthdateLookup([...manualMembers, ...registeredUsers])
|
const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
|
||||||
|
const visibleManualMembers = manualMembers.filter(member => {
|
||||||
|
const email = normalizeUserEmail(member.email)
|
||||||
|
return member.hidden !== true && member.invisible !== true && member.isHidden !== true && !hiddenUserEmails.has(email)
|
||||||
|
})
|
||||||
|
const visibleUsers = registeredUsers.filter(user => !isHiddenUser(user))
|
||||||
|
const hiddenNames = new Set([
|
||||||
|
...manualMembers.filter(member => member.hidden === true || member.invisible === true || member.isHidden === true || hiddenUserEmails.has(normalizeUserEmail(member.email))),
|
||||||
|
...registeredUsers.filter(isHiddenUser)
|
||||||
|
].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean))
|
||||||
|
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
rows: Array.isArray(payload.rows)
|
rows: Array.isArray(payload.rows)
|
||||||
? payload.rows.map((row) => ({
|
? payload.rows
|
||||||
...row,
|
.filter(row => !hiddenNames.has(normalizeName(row.playerName)))
|
||||||
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
|
.map((row) => ({
|
||||||
}))
|
...row,
|
||||||
|
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
|
||||||
|
}))
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { saveNews } from '../utils/news.js'
|
import { saveNews } from '../utils/news.js'
|
||||||
|
import { sendNewNewsPush } from '../utils/push-notifications.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveNews({
|
const newsEntry = {
|
||||||
id: id || undefined,
|
id: id || undefined,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
@@ -49,7 +50,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
expiresAt: expiresAt || undefined,
|
expiresAt: expiresAt || undefined,
|
||||||
isHidden: isHidden || false,
|
isHidden: isHidden || false,
|
||||||
author: user.name
|
author: user.name
|
||||||
})
|
}
|
||||||
|
await saveNews(newsEntry)
|
||||||
|
if (!id && !newsEntry.isHidden) {
|
||||||
|
sendNewNewsPush(newsEntry)
|
||||||
|
.then(result => {
|
||||||
|
console.info('News-Push Ergebnis:', { newsId: newsEntry.id, ...result })
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('News-Push konnte nicht gesendet werden:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
24
server/api/profile/notifications.get.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { verifyToken, getUserFromToken } from '../../utils/auth.js'
|
||||||
|
import { notificationSettingsForUser } from '../../utils/notification-settings.js'
|
||||||
|
|
||||||
|
function tokenFromEvent(event) {
|
||||||
|
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAuthenticatedUser(event) {
|
||||||
|
const token = tokenFromEvent(event)
|
||||||
|
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
|
||||||
|
return { token, decoded, user }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { user } = await requireAuthenticatedUser(event)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
settings: notificationSettingsForUser(user)
|
||||||
|
}
|
||||||
|
})
|
||||||
34
server/api/profile/notifications.put.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
|
||||||
|
import { sanitizeNotificationSettings } from '../../utils/notification-settings.js'
|
||||||
|
|
||||||
|
function tokenFromEvent(event) {
|
||||||
|
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAuthenticatedUser(event) {
|
||||||
|
const token = tokenFromEvent(event)
|
||||||
|
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
|
||||||
|
return { token, decoded, user }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { decoded } = await requireAuthenticatedUser(event)
|
||||||
|
const body = await readBody(event)
|
||||||
|
const settings = sanitizeNotificationSettings(body?.settings || body || {})
|
||||||
|
const users = await readUsers()
|
||||||
|
const userIndex = users.findIndex(user => user.id === decoded.id)
|
||||||
|
if (userIndex === -1) {
|
||||||
|
throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
|
||||||
|
}
|
||||||
|
users[userIndex].notificationSettings = settings
|
||||||
|
await writeUsers(users)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Benachrichtigungseinstellungen gespeichert.',
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
})
|
||||||
29
server/api/profile/push-token.post.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
|
||||||
|
import { upsertPushToken } from '../../utils/push-notifications.js'
|
||||||
|
|
||||||
|
function tokenFromEvent(event) {
|
||||||
|
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = tokenFromEvent(event)
|
||||||
|
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
|
||||||
|
const sessionUser = await getUserFromToken(token)
|
||||||
|
if (!sessionUser) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
|
||||||
|
const body = await readBody(event)
|
||||||
|
if (!body?.token || typeof body.token !== 'string') {
|
||||||
|
throw createError({ statusCode: 400, message: 'Push-Token fehlt.' })
|
||||||
|
}
|
||||||
|
const users = await readUsers()
|
||||||
|
const userIndex = users.findIndex(user => user.id === decoded.id)
|
||||||
|
if (userIndex === -1) throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
|
||||||
|
upsertPushToken(users[userIndex], {
|
||||||
|
token: body.token,
|
||||||
|
platform: body.platform || 'android',
|
||||||
|
appVersion: body.appVersion || null
|
||||||
|
})
|
||||||
|
await writeUsers(users)
|
||||||
|
return { success: true, message: 'Push-Token gespeichert.' }
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import crypto from 'crypto'
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { encryptObject, decryptObject } from './encryption.js'
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Export migrateUserRoles für Verwendung in anderen Modulen
|
// Export migrateUserRoles für Verwendung in anderen Modulen
|
||||||
export function migrateUserRoles(user) {
|
export function migrateUserRoles(user) {
|
||||||
@@ -26,6 +27,28 @@ export function migrateUserRoles(user) {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function normalizeUserEmail(email) {
|
||||||
|
return String(email || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredHiddenUserEmails() {
|
||||||
|
return [process.env.PLAYSTORE_REVIEW_EMAIL, process.env.HIDDEN_USER_EMAILS]
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap(value => String(value).split(','))
|
||||||
|
.map(normalizeUserEmail)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHiddenUser(user) {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.hidden === true || user.invisible === true || user.isHidden === true || user.systemAccount === true) return true
|
||||||
|
if (String(user.accountType || '').toLowerCase() === 'playstore_review') return true
|
||||||
|
|
||||||
|
const email = normalizeUserEmail(user.email)
|
||||||
|
return email ? configuredHiddenUserEmails().includes(email) : false
|
||||||
|
}
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production'
|
const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
@@ -196,7 +219,7 @@ export async function writeUsers(users) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(users, encryptionKey)
|
const encryptedData = encryptObject(users, encryptionKey)
|
||||||
await fs.writeFile(USERS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(USERS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
|
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
|
||||||
@@ -262,7 +285,7 @@ export async function writeSessions(sessions) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(sessions, encryptionKey)
|
const encryptedData = encryptObject(sessions, encryptionKey)
|
||||||
await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(SESSIONS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Sessions:', error)
|
console.error('Fehler beim Schreiben der Sessions:', error)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant, never user input
|
// filename is always a hardcoded constant, never user input
|
||||||
@@ -29,7 +30,7 @@ export async function readContactRequests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function writeContactRequests(items) {
|
export async function writeContactRequests(items) {
|
||||||
await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8')
|
await writeDataFileWithRotation(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), { encoding: 'utf-8' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContactRequest(data) {
|
export async function createContactRequest(data) {
|
||||||
|
|||||||
143
server/utils/data-file-rotation.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||||
|
|
||||||
|
function getProjectRoot() {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.resolve(cwd, '..')
|
||||||
|
}
|
||||||
|
return cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupRoot() {
|
||||||
|
const configured = process.env.DATA_FILE_BACKUP_DIR
|
||||||
|
if (!configured) {
|
||||||
|
return path.join(getProjectRoot(), 'backups', 'data-rotation')
|
||||||
|
}
|
||||||
|
if (path.isAbsolute(configured)) {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return path.join(getProjectRoot(), configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileKey(filePath) {
|
||||||
|
const projectRoot = getProjectRoot()
|
||||||
|
const relative = path.relative(projectRoot, filePath)
|
||||||
|
const normalized = relative.split(path.sep).join('__')
|
||||||
|
return normalized.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupName(date = new Date()) {
|
||||||
|
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||||
|
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDataFileBackupPath(backupDir, backupName) {
|
||||||
|
if (typeof backupName !== 'string' || !backupName.endsWith('.bak') || path.basename(backupName) !== backupName) {
|
||||||
|
throw new Error('Ungueltiger Backup-Dateiname')
|
||||||
|
}
|
||||||
|
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
const resolvedBackupDir = path.resolve(backupDir)
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
const resolvedBackupPath = path.resolve(resolvedBackupDir, backupName)
|
||||||
|
if (path.dirname(resolvedBackupPath) !== resolvedBackupDir) {
|
||||||
|
throw new Error('Backup-Datei liegt ausserhalb des Backup-Ordners')
|
||||||
|
}
|
||||||
|
return resolvedBackupPath
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDirectory(dirPath) {
|
||||||
|
await fs.mkdir(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateOldBackups(backupDir, maxBackups) {
|
||||||
|
if (!Number.isFinite(maxBackups) || maxBackups < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||||
|
const backups = entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
const overflowCount = Math.max(0, backups.length - maxBackups)
|
||||||
|
if (overflowCount === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDelete = backups.slice(0, overflowCount)
|
||||||
|
await Promise.all(toDelete.map((name) => fs.unlink(resolveDataFileBackupPath(backupDir, name)).catch(() => {})))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupDirectoryForDataFile(filePath) {
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDataFileBackups(filePath) {
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(filePath)
|
||||||
|
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeDataFileWithRotation(filePath, content, {
|
||||||
|
encoding = 'utf-8',
|
||||||
|
maxBackups = DEFAULT_MAX_BACKUPS
|
||||||
|
} = {}) {
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
await ensureDirectory(path.dirname(resolvedPath))
|
||||||
|
|
||||||
|
let existingContent = null
|
||||||
|
try {
|
||||||
|
existingContent = await fs.readFile(resolvedPath, encoding)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingContent !== null && existingContent === content) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
backupPath: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupPath = null
|
||||||
|
if (existingContent !== null) {
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||||
|
await ensureDirectory(backupDir)
|
||||||
|
backupPath = resolveDataFileBackupPath(backupDir, buildBackupName())
|
||||||
|
await fs.copyFile(resolvedPath, backupPath)
|
||||||
|
await rotateOldBackups(backupDir, maxBackups)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
await fs.writeFile(tmpPath, content, encoding)
|
||||||
|
await fs.rename(tmpPath, resolvedPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
backupPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreDataFileBackup(filePath, backupName, options = {}) {
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||||
|
const sourceBackupPath = resolveDataFileBackupPath(backupDir, backupName)
|
||||||
|
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
|
||||||
|
|
||||||
|
return writeDataFileWithRotation(resolvedPath, backupContent, options)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
|
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -192,7 +193,7 @@ export async function writeMembers(members) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(members, encryptionKey)
|
const encryptedData = encryptObject(members, encryptionKey)
|
||||||
await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(MEMBERS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Mitgliederdaten:', error)
|
console.error('Fehler beim Schreiben der Mitgliederdaten:', error)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -38,7 +39,7 @@ export async function readNews() {
|
|||||||
// Write news to file
|
// Write news to file
|
||||||
export async function writeNews(news) {
|
export async function writeNews(news) {
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(NEWS_FILE, JSON.stringify(news, null, 2), 'utf-8')
|
await writeDataFileWithRotation(NEWS_FILE, JSON.stringify(news, null, 2), { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der News:', error)
|
console.error('Fehler beim Schreiben der News:', error)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { readMembers } from './members.js'
|
import { readMembers } from './members.js'
|
||||||
import { readUsers } from './auth.js'
|
import { readUsers, isHiddenUser, normalizeUserEmail } from './auth.js'
|
||||||
import { encryptObject, decryptObject } from './encryption.js'
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input
|
// filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input
|
||||||
@@ -136,7 +137,7 @@ export async function writeSubscribers(subscribers) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(subscribers, encryptionKey)
|
const encryptedData = encryptObject(subscribers, encryptionKey)
|
||||||
await fs.writeFile(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)
|
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)
|
||||||
@@ -161,11 +162,11 @@ function calculateAge(geburtsdatum) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtert den Admin-User aus Empfängerliste heraus
|
// Filtert interne System-/Hidden-Accounts aus Empfängerliste heraus
|
||||||
function filterAdminUser(recipients) {
|
function filterInternalUsers(recipients, hiddenEmails = new Set()) {
|
||||||
return recipients.filter(r => {
|
return recipients.filter(r => {
|
||||||
const email = (r.email || '').toLowerCase().trim()
|
const email = normalizeUserEmail(r.email)
|
||||||
return email !== 'admin@harheimertc.de'
|
return email && email !== 'admin@harheimertc.de' && !hiddenEmails.has(email)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,20 +174,26 @@ function filterAdminUser(recipients) {
|
|||||||
export async function getRecipientsByGroup(targetGroup) {
|
export async function getRecipientsByGroup(targetGroup) {
|
||||||
const members = await readMembers()
|
const members = await readMembers()
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
|
const hiddenUserEmails = new Set(users.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
|
||||||
|
const visibleUsers = users.filter(user => !isHiddenUser(user))
|
||||||
|
const visibleMembers = members.filter(member => {
|
||||||
|
const email = normalizeUserEmail(member.email)
|
||||||
|
return member.hidden !== true && member.invisible !== true && member.isHidden !== true && !hiddenUserEmails.has(email)
|
||||||
|
})
|
||||||
|
|
||||||
let recipients = []
|
let recipients = []
|
||||||
|
|
||||||
switch (targetGroup) {
|
switch (targetGroup) {
|
||||||
case 'alle':
|
case 'alle':
|
||||||
// Alle Mitglieder mit E-Mail
|
// Alle Mitglieder mit E-Mail
|
||||||
recipients = members
|
recipients = visibleMembers
|
||||||
.filter(m => m.email && m.email.trim() !== '')
|
.filter(m => m.email && m.email.trim() !== '')
|
||||||
.map(m => ({
|
.map(m => ({
|
||||||
email: m.email,
|
email: m.email,
|
||||||
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
||||||
}))
|
}))
|
||||||
// Auch alle aktiven Benutzer hinzufügen
|
// Auch alle aktiven Benutzer hinzufügen
|
||||||
users
|
visibleUsers
|
||||||
.filter(u => u.active && u.email)
|
.filter(u => u.active && u.email)
|
||||||
.forEach(u => {
|
.forEach(u => {
|
||||||
if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) {
|
if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) {
|
||||||
@@ -200,7 +207,7 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
|
|
||||||
case 'erwachsene':
|
case 'erwachsene':
|
||||||
// Mitglieder über 18 Jahre
|
// Mitglieder über 18 Jahre
|
||||||
recipients = members
|
recipients = visibleMembers
|
||||||
.filter(m => {
|
.filter(m => {
|
||||||
if (!m.email || !m.email.trim()) return false
|
if (!m.email || !m.email.trim()) return false
|
||||||
const age = calculateAge(m.geburtsdatum)
|
const age = calculateAge(m.geburtsdatum)
|
||||||
@@ -211,7 +218,7 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
||||||
}))
|
}))
|
||||||
// Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum)
|
// Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum)
|
||||||
users
|
visibleUsers
|
||||||
.filter(u => u.active && u.email && u.email.trim())
|
.filter(u => u.active && u.email && u.email.trim())
|
||||||
.forEach(u => {
|
.forEach(u => {
|
||||||
// Prüfe ob bereits vorhanden
|
// Prüfe ob bereits vorhanden
|
||||||
@@ -226,7 +233,7 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
|
|
||||||
case 'nachwuchs':
|
case 'nachwuchs':
|
||||||
// Mitglieder unter 18 Jahre
|
// Mitglieder unter 18 Jahre
|
||||||
recipients = members
|
recipients = visibleMembers
|
||||||
.filter(m => {
|
.filter(m => {
|
||||||
if (!m.email || !m.email.trim()) return false
|
if (!m.email || !m.email.trim()) return false
|
||||||
const age = calculateAge(m.geburtsdatum)
|
const age = calculateAge(m.geburtsdatum)
|
||||||
@@ -238,7 +245,7 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Zusätzlich aktive Trainer aus users.json anschreiben
|
// Zusätzlich aktive Trainer aus users.json anschreiben
|
||||||
users
|
visibleUsers
|
||||||
.filter(u => {
|
.filter(u => {
|
||||||
if (!u.active || !u.email || !u.email.trim()) return false
|
if (!u.active || !u.email || !u.email.trim()) return false
|
||||||
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
|
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
|
||||||
@@ -256,7 +263,7 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
|
|
||||||
case 'mannschaftsspieler':
|
case 'mannschaftsspieler':
|
||||||
// Mitglieder die in einer Mannschaft spielen
|
// Mitglieder die in einer Mannschaft spielen
|
||||||
recipients = members
|
recipients = visibleMembers
|
||||||
.filter(m => {
|
.filter(m => {
|
||||||
if (!m.email || !m.email.trim()) return false
|
if (!m.email || !m.email.trim()) return false
|
||||||
// Prüfe ob als Mannschaftsspieler markiert
|
// Prüfe ob als Mannschaftsspieler markiert
|
||||||
@@ -275,7 +282,7 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
|
|
||||||
case 'vorstand':
|
case 'vorstand':
|
||||||
// Nur Vorstand (aus users.json)
|
// Nur Vorstand (aus users.json)
|
||||||
recipients = users
|
recipients = visibleUsers
|
||||||
.filter(u => {
|
.filter(u => {
|
||||||
if (!u.active || !u.email) return false
|
if (!u.active || !u.email) return false
|
||||||
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
|
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
|
||||||
@@ -292,12 +299,13 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin-User herausfiltern
|
// Admin-User herausfiltern
|
||||||
return filterAdminUser(recipients)
|
return filterInternalUsers(recipients, hiddenUserEmails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet)
|
// Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet)
|
||||||
export async function getNewsletterSubscribers(internalOnly = false, groupId = null) {
|
export async function getNewsletterSubscribers(internalOnly = false, groupId = null) {
|
||||||
const subscribers = await readSubscribers()
|
const subscribers = await readSubscribers()
|
||||||
|
const hiddenUserEmails = new Set((await readUsers()).filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean))
|
||||||
|
|
||||||
let confirmedSubscribers = subscribers.filter(s => {
|
let confirmedSubscribers = subscribers.filter(s => {
|
||||||
if (!s.confirmed || s.unsubscribedAt) {
|
if (!s.confirmed || s.unsubscribedAt) {
|
||||||
@@ -328,12 +336,12 @@ export async function getNewsletterSubscribers(internalOnly = false, groupId = n
|
|||||||
const members = await readMembers()
|
const members = await readMembers()
|
||||||
const memberEmails = new Set(
|
const memberEmails = new Set(
|
||||||
members
|
members
|
||||||
.filter(m => m.email)
|
.filter(m => m.email && m.hidden !== true && m.invisible !== true && m.isHidden !== true && !hiddenUserEmails.has(normalizeUserEmail(m.email)))
|
||||||
.map(m => m.email.toLowerCase())
|
.map(m => normalizeUserEmail(m.email))
|
||||||
)
|
)
|
||||||
|
|
||||||
confirmedSubscribers = confirmedSubscribers.filter(s =>
|
confirmedSubscribers = confirmedSubscribers.filter(s =>
|
||||||
memberEmails.has(s.email.toLowerCase())
|
memberEmails.has(normalizeUserEmail(s.email))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +351,7 @@ export async function getNewsletterSubscribers(internalOnly = false, groupId = n
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Admin-User herausfiltern
|
// Admin-User herausfiltern
|
||||||
return filterAdminUser(result)
|
return filterInternalUsers(result, hiddenUserEmails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generiert Abmelde-Token für Abonnenten
|
// Generiert Abmelde-Token für Abonnenten
|
||||||
|
|||||||
55
server/utils/notification-settings.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
|
||||||
|
newNews: false,
|
||||||
|
newEvents: false,
|
||||||
|
eventsToday: false,
|
||||||
|
eventsTomorrow: false,
|
||||||
|
ownTeamMatches: false,
|
||||||
|
allTeamMatches: false,
|
||||||
|
birthdays: false,
|
||||||
|
newContactRequest: false,
|
||||||
|
newUserRegistration: false,
|
||||||
|
selectedTeamSlugs: [],
|
||||||
|
selectedTeamSeason: null,
|
||||||
|
notificationTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
function coerceBoolean(value) {
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNotificationSettings(input = {}) {
|
||||||
|
const selectedTeamSlugs = Array.isArray(input.selectedTeamSlugs)
|
||||||
|
? input.selectedTeamSlugs
|
||||||
|
.map(value => String(value || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 50)
|
||||||
|
: []
|
||||||
|
const selectedTeamSeason = typeof input.selectedTeamSeason === 'string' && input.selectedTeamSeason.trim()
|
||||||
|
? input.selectedTeamSeason.trim().slice(0, 30)
|
||||||
|
: null
|
||||||
|
const notificationTime = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(input.notificationTime || ''))
|
||||||
|
? String(input.notificationTime)
|
||||||
|
: DEFAULT_NOTIFICATION_SETTINGS.notificationTime
|
||||||
|
|
||||||
|
return {
|
||||||
|
newNews: coerceBoolean(input.newNews),
|
||||||
|
newEvents: coerceBoolean(input.newEvents),
|
||||||
|
eventsToday: coerceBoolean(input.eventsToday),
|
||||||
|
eventsTomorrow: coerceBoolean(input.eventsTomorrow),
|
||||||
|
ownTeamMatches: coerceBoolean(input.ownTeamMatches),
|
||||||
|
allTeamMatches: coerceBoolean(input.allTeamMatches),
|
||||||
|
birthdays: coerceBoolean(input.birthdays),
|
||||||
|
newContactRequest: coerceBoolean(input.newContactRequest),
|
||||||
|
newUserRegistration: coerceBoolean(input.newUserRegistration),
|
||||||
|
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
|
||||||
|
selectedTeamSeason,
|
||||||
|
notificationTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationSettingsForUser(user) {
|
||||||
|
return sanitizeNotificationSettings({
|
||||||
|
...DEFAULT_NOTIFICATION_SETTINGS,
|
||||||
|
...(user?.notificationSettings || user?.notifications || {})
|
||||||
|
})
|
||||||
|
}
|
||||||
190
server/utils/push-notifications.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { readUsers, writeUsers, isHiddenUser } from './auth.js'
|
||||||
|
import { notificationSettingsForUser } from './notification-settings.js'
|
||||||
|
|
||||||
|
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
|
||||||
|
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||||
|
const tokenCache = { accessToken: null, expiresAt: 0 }
|
||||||
|
|
||||||
|
function base64Url(input) {
|
||||||
|
return Buffer.from(input).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectIdFromServiceAccount(serviceAccount) {
|
||||||
|
return process.env.FCM_PROJECT_ID || serviceAccount.project_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceAccountCandidatePaths() {
|
||||||
|
const filename = 'harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const candidates = []
|
||||||
|
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) candidates.push(process.env.GOOGLE_APPLICATION_CREDENTIALS)
|
||||||
|
candidates.push(path.join(cwd, 'server/data', filename))
|
||||||
|
candidates.push(path.join(cwd, '../server/data', filename))
|
||||||
|
return [...new Set(candidates)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readServiceAccount() {
|
||||||
|
if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
|
||||||
|
return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
|
||||||
|
}
|
||||||
|
for (const candidate of serviceAccountCandidatePaths()) {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(candidate, 'utf8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') {
|
||||||
|
console.warn(`FCM Service-Account konnte nicht gelesen werden (${candidate}): ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessToken(serviceAccount) {
|
||||||
|
if (tokenCache.accessToken && tokenCache.expiresAt > Date.now() + 60_000) {
|
||||||
|
return tokenCache.accessToken
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const assertion = [
|
||||||
|
base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })),
|
||||||
|
base64Url(JSON.stringify({
|
||||||
|
iss: serviceAccount.client_email,
|
||||||
|
scope: FCM_SCOPE,
|
||||||
|
aud: TOKEN_URL,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600
|
||||||
|
}))
|
||||||
|
].join('.')
|
||||||
|
const signature = crypto
|
||||||
|
.createSign('RSA-SHA256')
|
||||||
|
.update(assertion)
|
||||||
|
.sign(serviceAccount.private_key, 'base64url')
|
||||||
|
const response = await fetch(TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
assertion: `${assertion}.${signature}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`FCM OAuth fehlgeschlagen: ${response.status}`)
|
||||||
|
}
|
||||||
|
const body = await response.json()
|
||||||
|
tokenCache.accessToken = body.access_token
|
||||||
|
tokenCache.expiresAt = Date.now() + Number(body.expires_in || 3600) * 1000
|
||||||
|
return tokenCache.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushTokensForUser(user) {
|
||||||
|
return Array.isArray(user.pushTokens)
|
||||||
|
? user.pushTokens.filter(entry => entry?.token && entry.platform === 'android')
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
|
||||||
|
const normalizedToken = String(token || '').trim()
|
||||||
|
if (!normalizedToken) return user
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
|
||||||
|
const next = tokens.filter(entry => entry?.token !== normalizedToken)
|
||||||
|
next.push({
|
||||||
|
token: normalizedToken,
|
||||||
|
platform: String(platform || 'android').slice(0, 30),
|
||||||
|
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
|
||||||
|
updatedAt: now,
|
||||||
|
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
|
||||||
|
})
|
||||||
|
user.pushTokens = next.slice(-20)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, data = {} }) {
|
||||||
|
const projectId = projectIdFromServiceAccount(serviceAccount)
|
||||||
|
if (!projectId) throw new Error('FCM project_id fehlt.')
|
||||||
|
const response = await fetch(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: {
|
||||||
|
token,
|
||||||
|
notification: { title, body },
|
||||||
|
data,
|
||||||
|
android: {
|
||||||
|
priority: 'high',
|
||||||
|
notification: {
|
||||||
|
channel_id: 'harheimer_tc_updates',
|
||||||
|
click_action: 'OPEN_NEWS'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNewNewsPush(news) {
|
||||||
|
const serviceAccount = await readServiceAccount()
|
||||||
|
if (!serviceAccount) {
|
||||||
|
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
||||||
|
return { sent: 0, skipped: true }
|
||||||
|
}
|
||||||
|
const accessToken = await getAccessToken(serviceAccount)
|
||||||
|
const users = await readUsers()
|
||||||
|
let sent = 0
|
||||||
|
let failed = 0
|
||||||
|
let removed = 0
|
||||||
|
let recipients = 0
|
||||||
|
let tokenCount = 0
|
||||||
|
let changed = false
|
||||||
|
const title = 'Neue News'
|
||||||
|
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
|
||||||
|
const data = {
|
||||||
|
type: 'news',
|
||||||
|
newsId: String(news.id || ''),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (isHiddenUser(user)) continue
|
||||||
|
const settings = notificationSettingsForUser(user)
|
||||||
|
if (!settings.newNews) continue
|
||||||
|
recipients += 1
|
||||||
|
const tokens = pushTokensForUser(user)
|
||||||
|
tokenCount += tokens.length
|
||||||
|
const validTokens = []
|
||||||
|
for (const entry of tokens) {
|
||||||
|
try {
|
||||||
|
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data })
|
||||||
|
sent += 1
|
||||||
|
validTokens.push(entry)
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1
|
||||||
|
console.error('FCM News-Push fehlgeschlagen:', error.message)
|
||||||
|
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
||||||
|
validTokens.push(entry)
|
||||||
|
} else {
|
||||||
|
removed += 1
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validTokens.length !== tokens.length) {
|
||||||
|
user.pushTokens = validTokens
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) await writeUsers(users)
|
||||||
|
return { sent, failed, removed, recipients, tokenCount, skipped: false }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Use internal server/data directory for Termine CSV to avoid writing to public/
|
// Use internal server/data directory for Termine CSV to avoid writing to public/
|
||||||
const getDataPath = (filename) => {
|
const getDataPath = (filename) => {
|
||||||
@@ -89,7 +90,7 @@ export async function writeTermine(termine) {
|
|||||||
csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n`
|
csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(TERMINE_FILE, csv, 'utf-8')
|
await writeDataFileWithRotation(TERMINE_FILE, csv, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Termine:', error)
|
console.error('Fehler beim Schreiben der Termine:', error)
|
||||||
|
|||||||
BIN
temp/device-35433-after-intern-fix.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
temp/device-35433-cms-after-split-2.png
Normal file
|
After Width: | Height: | Size: 911 KiB |
BIN
temp/device-35433-cms-after-split.png
Normal file
|
After Width: | Height: | Size: 911 KiB |
BIN
temp/device-35433-cms-open.png
Normal file
|
After Width: | Height: | Size: 873 KiB |
BIN
temp/device-35433-home.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
temp/device-35433-intern-after-split.png
Normal file
|
After Width: | Height: | Size: 907 KiB |
0
temp/device-37165-arrow-check.png
Normal file
BIN
temp/device-38281-after-fix.png
Normal file
|
After Width: | Height: | Size: 899 KiB |
BIN
temp/device-38281-cms-2.png
Normal file
|
After Width: | Height: | Size: 899 KiB |
BIN
temp/device-38281-cms-open.png
Normal file
|
After Width: | Height: | Size: 944 KiB |
BIN
temp/device-38281-cms.png
Normal file
|
After Width: | Height: | Size: 899 KiB |