Compare commits
2 Commits
530e544542
...
5da11d2e4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da11d2e4d | ||
|
|
e8a50e55ca |
@@ -7,6 +7,10 @@ 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()
|
||||||
@@ -253,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")
|
||||||
@@ -262,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")
|
||||||
|
|||||||
Binary file not shown.
@@ -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"
|
||||||
@@ -16,6 +17,13 @@
|
|||||||
<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,17 @@
|
|||||||
package de.harheimertc
|
package de.harheimertc
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
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.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,12 +21,25 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
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()
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -257,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 = "",
|
||||||
@@ -660,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,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)
|
||||||
|
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.harheimertc.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
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.R
|
||||||
|
|
||||||
|
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,
|
||||||
|
): 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)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -416,6 +416,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.Qttr.route,
|
Destinations.Qttr.route,
|
||||||
Destinations.MemberNews.route,
|
Destinations.MemberNews.route,
|
||||||
Destinations.Profile.route,
|
Destinations.Profile.route,
|
||||||
|
Destinations.NotificationSettings.route,
|
||||||
Destinations.MemberApi.route -> MenuSection.INTERN
|
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||||
|
|
||||||
Destinations.CmsStartseite.route,
|
Destinations.CmsStartseite.route,
|
||||||
@@ -473,6 +474,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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
|
||||||
@@ -32,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) {
|
||||||
@@ -136,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,
|
||||||
)
|
)
|
||||||
@@ -146,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,
|
||||||
)
|
)
|
||||||
@@ -305,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,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -37,6 +38,7 @@ class NavigationViewModel @Inject constructor(
|
|||||||
private val loginRepository: LoginRepository,
|
private val loginRepository: LoginRepository,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val connectivityMonitor: ConnectivityMonitor,
|
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
|
||||||
@@ -61,13 +63,15 @@ class NavigationViewModel @Inject constructor(
|
|||||||
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 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 = hasStoredSession || status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = status.navigationRoles(),
|
roles = status.navigationRoles(),
|
||||||
connectionNote = null,
|
connectionNote = null,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +79,19 @@ class NavigationViewModel @Inject constructor(
|
|||||||
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 hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||||
|
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
loggedIn = hasStoredSession || status.isLoggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = status.navigationRoles(),
|
roles = status.navigationRoles(),
|
||||||
connectionNote = _state.value.connectionNote,
|
connectionNote = _state.value.connectionNote,
|
||||||
)
|
)
|
||||||
|
if (loggedIn) registerPushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPushToken() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
pushTokenRepository.registerCurrentDevice()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ fun MannschaftenScreen(
|
|||||||
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 {
|
||||||
@@ -161,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),
|
||||||
@@ -229,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) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,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.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,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",
|
||||||
|
|||||||
@@ -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 später aus den Mannschaftsdefinitionen der aktuellen Saison 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=24
|
ANDROID_VERSION_CODE=25
|
||||||
ANDROID_VERSION_NAME=0.9.19
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,13 @@ 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).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
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
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
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.' }
|
||||||
|
})
|
||||||
55
server/utils/notification-settings.js
Normal file
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 || {})
|
||||||
|
})
|
||||||
|
}
|
||||||
165
server/utils/push-notifications.js
Normal file
165
server/utils/push-notifications.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readServiceAccount() {
|
||||||
|
if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
|
||||||
|
return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
|
||||||
|
}
|
||||||
|
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||||
|
const raw = await fs.readFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
}
|
||||||
|
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 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
|
||||||
|
const tokens = pushTokensForUser(user)
|
||||||
|
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) {
|
||||||
|
console.error('FCM News-Push fehlgeschlagen:', error.message)
|
||||||
|
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
||||||
|
validTokens.push(entry)
|
||||||
|
} else {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validTokens.length !== tokens.length) {
|
||||||
|
user.pushTokens = validTokens
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) await writeUsers(users)
|
||||||
|
return { sent, skipped: false }
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ import configGetHandler from '../server/api/config.get.js'
|
|||||||
import configPutHandler from '../server/api/config.put.js'
|
import configPutHandler from '../server/api/config.put.js'
|
||||||
import profileGetHandler from '../server/api/profile.get.js'
|
import profileGetHandler from '../server/api/profile.get.js'
|
||||||
import profilePutHandler from '../server/api/profile.put.js'
|
import profilePutHandler from '../server/api/profile.put.js'
|
||||||
|
import profileNotificationsGetHandler from '../server/api/profile/notifications.get.js'
|
||||||
|
import profileNotificationsPutHandler from '../server/api/profile/notifications.put.js'
|
||||||
|
import profilePushTokenHandler from '../server/api/profile/push-token.post.js'
|
||||||
|
|
||||||
const invalidCurrentPassword = ['invalid', 'test', 'pw'].join('-')
|
const invalidCurrentPassword = ['invalid', 'test', 'pw'].join('-')
|
||||||
const validCurrentPassword = ['valid', 'test', 'pw'].join('-')
|
const validCurrentPassword = ['valid', 'test', 'pw'].join('-')
|
||||||
@@ -157,6 +160,113 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('GET /api/profile/notifications', () => {
|
||||||
|
it('verlangt Authentifizierung', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
|
||||||
|
await expect(profileNotificationsGetHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert Defaults plus gespeicherte Benachrichtigungseinstellungen', async () => {
|
||||||
|
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({
|
||||||
|
id: '1',
|
||||||
|
notificationSettings: {
|
||||||
|
eventsToday: true,
|
||||||
|
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
|
||||||
|
selectedTeamSeason: '2025/2026',
|
||||||
|
notificationTime: '07:30'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await profileNotificationsGetHandler(event)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.settings.eventsToday).toBe(true)
|
||||||
|
expect(result.settings.newEvents).toBe(false)
|
||||||
|
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
|
||||||
|
expect(result.settings.notificationTime).toBe('07:30')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PUT /api/profile/notifications', () => {
|
||||||
|
it('verlangt Authentifizierung', async () => {
|
||||||
|
const event = createEvent({ body: { eventsToday: true } })
|
||||||
|
|
||||||
|
await expect(profileNotificationsPutHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('speichert sanitizte Benachrichtigungseinstellungen am Benutzer', async () => {
|
||||||
|
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||||
|
mockSuccessReadBody({
|
||||||
|
newEvents: true,
|
||||||
|
eventsToday: 'true',
|
||||||
|
birthdays: true,
|
||||||
|
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
|
||||||
|
selectedTeamSeason: '2026/2027',
|
||||||
|
notificationTime: '25:99'
|
||||||
|
})
|
||||||
|
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue(users[0])
|
||||||
|
authUtils.readUsers.mockResolvedValue(users)
|
||||||
|
authUtils.writeUsers.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const result = await profileNotificationsPutHandler(event)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.settings.newEvents).toBe(true)
|
||||||
|
expect(result.settings.eventsToday).toBe(false)
|
||||||
|
expect(result.settings.birthdays).toBe(true)
|
||||||
|
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
|
||||||
|
expect(result.settings.notificationTime).toBe('09:00')
|
||||||
|
expect(authUtils.writeUsers).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: '1',
|
||||||
|
notificationSettings: expect.objectContaining({
|
||||||
|
newEvents: true,
|
||||||
|
birthdays: true,
|
||||||
|
selectedTeamSeason: '2026/2027'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe('POST /api/profile/push-token', () => {
|
||||||
|
it('verlangt Authentifizierung', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ token: 'fcm-token' })
|
||||||
|
|
||||||
|
await expect(profilePushTokenHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('speichert Android-Push-Token am Benutzer', async () => {
|
||||||
|
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||||
|
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })
|
||||||
|
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue(users[0])
|
||||||
|
authUtils.readUsers.mockResolvedValue(users)
|
||||||
|
authUtils.writeUsers.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const result = await profilePushTokenHandler(event)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(authUtils.writeUsers).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: '1',
|
||||||
|
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
describe('PUT /api/profile', () => {
|
describe('PUT /api/profile', () => {
|
||||||
it('verlangt Authentifizierung', async () => {
|
it('verlangt Authentifizierung', async () => {
|
||||||
const event = createEvent()
|
const event = createEvent()
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ vi.mock('../server/utils/news.js', () => ({
|
|||||||
deleteNews: vi.fn()
|
deleteNews: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/push-notifications.js', () => ({
|
||||||
|
sendNewNewsPush: vi.fn().mockResolvedValue({ sent: 1, skipped: false })
|
||||||
|
}))
|
||||||
|
|
||||||
const authUtils = await import('../server/utils/auth.js')
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
const newsUtils = await import('../server/utils/news.js')
|
const newsUtils = await import('../server/utils/news.js')
|
||||||
|
const pushUtils = await import('../server/utils/push-notifications.js')
|
||||||
|
|
||||||
import newsGetHandler from '../server/api/news.get.js'
|
import newsGetHandler from '../server/api/news.get.js'
|
||||||
import newsPostHandler from '../server/api/news.post.js'
|
import newsPostHandler from '../server/api/news.post.js'
|
||||||
@@ -111,6 +116,29 @@ describe('News API Endpoints', () => {
|
|||||||
expect(newsUtils.saveNews).toHaveBeenCalledWith(
|
expect(newsUtils.saveNews).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true })
|
expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true })
|
||||||
)
|
)
|
||||||
|
expect(pushUtils.sendNewNewsPush).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendet keinen Push bei News-Update', async () => {
|
||||||
|
const event = adminEvent()
|
||||||
|
newsUtils.saveNews.mockResolvedValue(undefined)
|
||||||
|
mockSuccessReadBody({ id: 'existing-news', title: 'Update', content: 'Inhalt' })
|
||||||
|
|
||||||
|
await newsPostHandler(event)
|
||||||
|
|
||||||
|
expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendet keinen Push bei versteckten News', async () => {
|
||||||
|
const event = adminEvent()
|
||||||
|
newsUtils.saveNews.mockResolvedValue(undefined)
|
||||||
|
mockSuccessReadBody({ title: 'Intern', content: 'Inhalt', isHidden: true })
|
||||||
|
|
||||||
|
await newsPostHandler(event)
|
||||||
|
|
||||||
|
expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setzt autor auf den angemeldeten Benutzer', async () => {
|
it('setzt autor auf den angemeldeten Benutzer', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user