feat: implement LiveTicker functionality with sound notifications and UI updates
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m11s

This commit is contained in:
Torsten Schulz (local)
2026-10-22 23:57:22 +02:00
parent 6b9198ac41
commit 6369249f40
10 changed files with 250 additions and 8 deletions

View File

@@ -37,6 +37,22 @@ data class SpielplanResponse(
val season: String? = null,
val seasons: List<SeasonDto> = emptyList(),
)
data class LiveTickerResponse(
val matches: List<LiveTickerMatchDto> = emptyList(),
val updatedAt: String? = null,
)
data class LiveTickerMatchDto(
val meetingId: String = "",
val homeTeam: String = "",
val awayTeam: String = "",
val score: String = "",
val startsAt: Long = 0,
val hasLiveData: Boolean = false,
val completed: Boolean = false,
val completedAt: Long? = null,
val updatedAt: String? = null,
val error: String? = null,
)
data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse(
val success: Boolean = false,
@@ -726,6 +742,9 @@ interface ApiService {
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/mitgliederbereich/live-ticker")
suspend fun liveTicker(): Response<LiveTickerResponse>
@GET("/api/members")
suspend fun members(): Response<MembersResponse>

View File

@@ -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<List<LiveTickerMatchDto>> = runCatching {
retryOnNetworkFailure {
val response = api.liveTicker()
if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.")
response.body()?.matches ?: emptyList()
}
}
}

View File

@@ -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<LiveTickerMatchDto>, loaded: Boolean) {
var observedLoadedState by remember { mutableStateOf(false) }
var previousStates by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(matches, loaded) {
if (!loaded) {
observedLoadedState = false
previousStates = emptyMap()
return@LaunchedEffect
}
val currentStates = matches.associate { match ->
(match.meetingId.ifBlank { "${match.homeTeam}-${match.awayTeam}-${match.startsAt}" }) to
"${match.hasLiveData}|${match.score}|${match.completed}"
}
if (observedLoadedState && currentStates.any { (id, state) -> previousStates[id] != state }) {
LiveTickerSound.play()
}
previousStates = currentStates
observedLoadedState = true
}
if (matches.isEmpty()) return
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,
)
}
}
}
}

View File

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

View File

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

View File

@@ -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<String> = emptySet(),
val connectionNote: String? = null,
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
val liveTickerLoaded: Boolean = false,
) {
val isAdmin: Boolean get() = "admin" in roles
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
@@ -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<NavigationUiState> = _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(