diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 690fd28..d2ba996 100755 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -37,6 +37,22 @@ data class SpielplanResponse( val season: String? = null, val seasons: List = emptyList(), ) +data class LiveTickerResponse( + val matches: List = emptyList(), + val updatedAt: String? = null, +) +data class LiveTickerMatchDto( + val meetingId: String = "", + val homeTeam: String = "", + val awayTeam: String = "", + val score: String = "", + val startsAt: Long = 0, + val hasLiveData: Boolean = false, + val completed: Boolean = false, + val completedAt: Long? = null, + val updatedAt: String? = null, + val error: String? = null, +) data class SeasonDto(val slug: String = "", val label: String = "") data class MannschaftenSeasonsResponse( val success: Boolean = false, @@ -270,6 +286,9 @@ data class NotificationSettingsDto( val newContactRequest: Boolean = false, val newUserRegistration: Boolean = false, val ownTtrChanges: Boolean = false, + val liveTickerStarts: Boolean = false, + val liveTickerScoreChanges: Boolean = false, + val liveTickerFinished: Boolean = false, val selectedTeamSlugs: List = emptyList(), val selectedTeamSeason: String? = null, val notificationTime: String = "09:00", @@ -726,6 +745,9 @@ interface ApiService { @GET("/api/mitgliederbereich/qttr") suspend fun qttrValues(): Response + @GET("/api/mitgliederbereich/live-ticker") + suspend fun liveTicker(): Response + @GET("/api/members") suspend fun members(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt index 18c6762..ef0ceaf 100755 --- a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -81,6 +81,7 @@ object HarheimerNotifications { "news", "news_expiring" -> Destinations.MemberNews.route "event", "events_today", "events_tomorrow" -> Destinations.Termine.route "team_matches" -> Destinations.Spielplan.route + "live_ticker_started", "live_ticker_score_changed", "live_ticker_finished" -> Destinations.MemberArea.route "qttr_list_updated", "ttr_change" -> Destinations.Qttr.route "birthdays" -> Destinations.MemberArea.route "contact_request" -> Destinations.CmsContactRequests.route diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/LiveTickerRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/LiveTickerRepository.kt new file mode 100644 index 0000000..00d7d25 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/LiveTickerRepository.kt @@ -0,0 +1,17 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.LiveTickerMatchDto +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LiveTickerRepository @Inject constructor(private val api: ApiService) { + suspend fun fetch(): Result> = runCatching { + retryOnNetworkFailure { + val response = api.liveTicker() + if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.") + response.body()?.matches ?: emptyList() + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt index e2de8ee..9656b2e 100755 --- a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt @@ -20,6 +20,9 @@ data class NotificationPreferences( val newContactRequest: Boolean = false, val newUserRegistration: Boolean = false, val ownTtrChanges: Boolean = false, + val liveTickerStarts: Boolean = false, + val liveTickerScoreChanges: Boolean = false, + val liveTickerFinished: Boolean = false, val selectedTeamSlugs: Set = emptySet(), val selectedTeamSeason: String? = null, val notificationTime: String = DEFAULT_NOTIFICATION_TIME, @@ -45,6 +48,9 @@ class NotificationPreferencesRepository @Inject constructor( newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false), newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false), ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false), + liveTickerStarts = preferences.getBoolean(KEY_LIVE_TICKER_STARTS, false), + liveTickerScoreChanges = preferences.getBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, false), + liveTickerFinished = preferences.getBoolean(KEY_LIVE_TICKER_FINISHED, false), selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(), selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() }, notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME, @@ -72,6 +78,9 @@ class NotificationPreferencesRepository @Inject constructor( .putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest) .putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration) .putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges) + .putBoolean(KEY_LIVE_TICKER_STARTS, settings.liveTickerStarts) + .putBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, settings.liveTickerScoreChanges) + .putBoolean(KEY_LIVE_TICKER_FINISHED, settings.liveTickerFinished) .putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs) .putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason) .putString(KEY_NOTIFICATION_TIME, settings.notificationTime) @@ -102,6 +111,9 @@ class NotificationPreferencesRepository @Inject constructor( const val KEY_NEW_CONTACT_REQUEST = "new_contact_request" const val KEY_NEW_USER_REGISTRATION = "new_user_registration" const val KEY_OWN_TTR_CHANGES = "own_ttr_changes" + const val KEY_LIVE_TICKER_STARTS = "live_ticker_starts" + const val KEY_LIVE_TICKER_SCORE_CHANGES = "live_ticker_score_changes" + const val KEY_LIVE_TICKER_FINISHED = "live_ticker_finished" const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs" const val KEY_SELECTED_TEAM_SEASON = "selected_team_season" const val KEY_NOTIFICATION_TIME = "notification_time" @@ -119,6 +131,9 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N newContactRequest = newContactRequest, newUserRegistration = newUserRegistration, ownTtrChanges = ownTtrChanges, + liveTickerStarts = liveTickerStarts, + liveTickerScoreChanges = liveTickerScoreChanges, + liveTickerFinished = liveTickerFinished, selectedTeamSlugs = selectedTeamSlugs.toSet(), selectedTeamSeason = selectedTeamSeason, notificationTime = notificationTime, @@ -135,6 +150,9 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat newContactRequest = newContactRequest, newUserRegistration = newUserRegistration, ownTtrChanges = ownTtrChanges, + liveTickerStarts = liveTickerStarts, + liveTickerScoreChanges = liveTickerScoreChanges, + liveTickerFinished = liveTickerFinished, selectedTeamSlugs = selectedTeamSlugs.toList(), selectedTeamSeason = selectedTeamSeason, notificationTime = notificationTime, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt new file mode 100644 index 0000000..0243fe7 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt @@ -0,0 +1,78 @@ +package de.harheimertc.ui.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.harheimertc.data.LiveTickerMatchDto + +@Composable +fun LiveTickerBanner(matches: List, loaded: Boolean) { + var observedLoadedState by remember { mutableStateOf(false) } + var previousStates by remember { mutableStateOf>(emptyMap()) } + + LaunchedEffect(matches, loaded) { + if (!loaded) { + observedLoadedState = false + previousStates = emptyMap() + return@LaunchedEffect + } + val currentStates = matches.associate { match -> + (match.meetingId.ifBlank { "${match.homeTeam}-${match.awayTeam}-${match.startsAt}" }) to + "${match.hasLiveData}|${match.score}|${match.completed}" + } + if (observedLoadedState && currentStates.any { (id, state) -> previousStates[id] != state }) { + LiveTickerSound.play() + } + previousStates = currentStates + observedLoadedState = true + } + + if (matches.isEmpty()) return + + Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 9.dp), + ) { + matches.forEachIndexed { index, match -> + if (index > 0) { + Text(" • ", color = Color(0xFF64748B)) + } + val status = when { + match.completed -> "✓ beendet" + match.hasLiveData -> "● LIVE" + else -> "◌ Keine Live-Daten" + } + Text( + text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}", + color = when { + match.completed -> Color(0xFF64748B) + match.hasLiveData -> Color(0xFFB91C1C) + else -> Color(0xFF334155) + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold, + fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal, + maxLines = 1, + ) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerSound.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerSound.kt new file mode 100644 index 0000000..be4c21a --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerSound.kt @@ -0,0 +1,15 @@ +package de.harheimertc.ui.components + +import android.media.AudioManager +import android.media.ToneGenerator + +/** A generated system tone; no audio asset or third-party licence is required. */ +object LiveTickerSound { + private val toneGenerator by lazy { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 65) } + + fun play() { + runCatching { + toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP2, 170) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index 74b063a..4d6f3e1 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -25,6 +25,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import de.harheimertc.ui.components.AppNavigationHeader +import de.harheimertc.ui.components.LiveTickerBanner @Composable fun NavGraph( @@ -44,6 +45,11 @@ fun NavGraph( LaunchedEffect(currentRoute) { navigationViewModel.refreshSession() } + LaunchedEffect(currentRoute, navigationState.loggedIn) { + navigationViewModel.setLiveTickerActive( + navigationState.loggedIn && (currentRoute == "intern" || currentRoute?.startsWith("intern/") == true || currentRoute == "cms" || currentRoute?.startsWith("cms/") == true), + ) + } BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val persistentNavigation = maxWidth >= 600.dp Column(modifier = Modifier.fillMaxSize()) { @@ -72,6 +78,10 @@ fun NavGraph( navigationState = navigationState, ) } + LiveTickerBanner( + matches = navigationState.liveTickerMatches, + loaded = navigationState.liveTickerLoaded, + ) NavHost( navController = navController, startDestination = startDestination, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt index 84ed639..41c45da 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt @@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.data.ConnectivityMonitor +import de.harheimertc.data.LiveTickerMatchDto import de.harheimertc.repositories.AuthRepository import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.LoginRepository +import de.harheimertc.repositories.LiveTickerRepository import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.PushTokenRepository import kotlinx.coroutines.async +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,6 +27,8 @@ data class NavigationUiState( val loggedIn: Boolean = false, val roles: Set = emptySet(), val connectionNote: String? = null, + val liveTickerMatches: List = emptyList(), + val liveTickerLoaded: Boolean = false, ) { val isAdmin: Boolean get() = "admin" in roles val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") } @@ -39,9 +46,12 @@ class NavigationViewModel @Inject constructor( private val authRepository: AuthRepository, private val connectivityMonitor: ConnectivityMonitor, private val pushTokenRepository: PushTokenRepository, + private val liveTickerRepository: LiveTickerRepository, ) : ViewModel() { private val _state = MutableStateFlow(NavigationUiState()) val state: StateFlow = _state + private var tickerJob: Job? = null + private var tickerActive = false init { loadNavigationData() @@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor( loggedIn = loggedIn, roles = status.navigationRoles(), connectionNote = null, + liveTickerMatches = _state.value.liveTickerMatches, + liveTickerLoaded = _state.value.liveTickerLoaded, ) if (loggedIn) registerPushToken() } @@ -89,6 +101,24 @@ class NavigationViewModel @Inject constructor( } } + fun setLiveTickerActive(active: Boolean) { + if (tickerActive == active) return + tickerActive = active + tickerJob?.cancel() + if (!active) { + _state.value = _state.value.copy(liveTickerMatches = emptyList(), liveTickerLoaded = false) + return + } + tickerJob = viewModelScope.launch { + while (isActive) { + liveTickerRepository.fetch().onSuccess { matches -> + _state.value = _state.value.copy(liveTickerMatches = matches, liveTickerLoaded = true) + } + delay(30_000) + } + } + } + private fun registerPushToken() { viewModelScope.launch { pushTokenRepository.registerCurrentDevice() @@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor( } fun logout(onComplete: () -> Unit = {}) { + setLiveTickerActive(false) viewModelScope.launch { loginRepository.logout() _state.value = _state.value.copy( diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt index 4fb09da..f4a3f99 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt @@ -164,6 +164,15 @@ fun NotificationSettingsScreen( ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) { viewModel.update(state.settings.copy(allTeamMatches = it)) } + ToggleRow("Live-Spiel gestartet (Live-Ticker)", state.settings.liveTickerStarts) { + viewModel.update(state.settings.copy(liveTickerStarts = it)) + } + ToggleRow("Live-Ergebnis geändert", state.settings.liveTickerScoreChanges) { + viewModel.update(state.settings.copy(liveTickerScoreChanges = it)) + } + ToggleRow("Live-Spiel beendet", state.settings.liveTickerFinished) { + viewModel.update(state.settings.copy(liveTickerFinished = it)) + } TeamSelection( teams = state.teams, seasons = state.seasons, diff --git a/android-app/gradle.properties b/android-app/gradle.properties index a6b0cd0..9b70bac 100755 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=37 -ANDROID_VERSION_NAME=0.10.1 +ANDROID_VERSION_CODE=38 +ANDROID_VERSION_NAME=0.11.0 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/app.vue b/app.vue index ee6b2ed..6493c6b 100755 --- a/app.vue +++ b/app.vue @@ -1,6 +1,7 @@