dev #59
@@ -88,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||||
saveLocal(settings)
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
retryOnNetworkFailure {
|
retryOnNetworkFailure {
|
||||||
val response = api.updateNotificationSettings(settings.toDto())
|
val response = api.updateNotificationSettings(settings.toDto())
|
||||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||||
saveLocal(saved)
|
requireLiveNotificationSettingsSaved(settings, saved)
|
||||||
saved
|
saved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +119,15 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun requireLiveNotificationSettingsSaved(requested: NotificationPreferences, saved: NotificationPreferences) {
|
||||||
|
if (requested.liveTickerStarts != saved.liveTickerStarts ||
|
||||||
|
requested.liveTickerScoreChanges != saved.liveTickerScoreChanges ||
|
||||||
|
requested.liveTickerFinished != saved.liveTickerFinished
|
||||||
|
) {
|
||||||
|
error("Der Server hat die Live-Benachrichtigungen nicht übernommen. Bitte das Backend aktualisieren und erneut versuchen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||||
newNews = newNews,
|
newNews = newNews,
|
||||||
newEvents = newEvents,
|
newEvents = newEvents,
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
package de.harheimertc.ui.components
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.harheimertc.data.LiveTickerMatchDto
|
import de.harheimertc.data.LiveTickerMatchDto
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||||
@@ -45,34 +60,78 @@ fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
|||||||
|
|
||||||
if (matches.isEmpty()) return
|
if (matches.isEmpty()) return
|
||||||
|
|
||||||
|
var paused by remember { mutableStateOf(false) }
|
||||||
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
|
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
modifier = Modifier
|
TextButton(
|
||||||
.horizontalScroll(rememberScrollState())
|
onClick = { paused = !paused },
|
||||||
.padding(horizontal = 16.dp, vertical = 9.dp),
|
modifier = Modifier.semantics {
|
||||||
) {
|
contentDescription = if (paused) "Laufschrift fortsetzen" else "Laufschrift anhalten"
|
||||||
matches.forEachIndexed { index, match ->
|
},
|
||||||
if (index > 0) {
|
) {
|
||||||
Text(" • ", color = Color(0xFF64748B))
|
Text(if (paused) "▶" else "Ⅱ", color = Color(0xFF334155))
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier = Modifier.weight(1f).clipToBounds()) {
|
||||||
|
val viewportWidth = maxWidth
|
||||||
|
var groupWidth by remember { mutableStateOf(0) }
|
||||||
|
val offset = remember { Animatable(0f) }
|
||||||
|
val pixelsPerSecond = with(LocalDensity.current) { 36.dp.toPx() }
|
||||||
|
LaunchedEffect(groupWidth, pixelsPerSecond, paused) {
|
||||||
|
if (groupWidth <= 0 || paused) return@LaunchedEffect
|
||||||
|
offset.snapTo(offset.value % groupWidth)
|
||||||
|
while (isActive) {
|
||||||
|
val duration = ((groupWidth - offset.value) / pixelsPerSecond * 1000).toInt().coerceAtLeast(1)
|
||||||
|
offset.animateTo(groupWidth.toFloat(), tween(duration, easing = LinearEasing))
|
||||||
|
offset.snapTo(0f)
|
||||||
|
// Also yield when system animations are disabled.
|
||||||
|
withFrameNanos { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val status = when {
|
Row(
|
||||||
match.completed -> "✓ beendet"
|
modifier = Modifier
|
||||||
match.hasLiveData -> "● LIVE"
|
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||||
else -> "◌ Keine Live-Daten"
|
.graphicsLayer { translationX = -offset.value },
|
||||||
|
) {
|
||||||
|
repeat(2) { copy ->
|
||||||
|
LiveTickerGroup(
|
||||||
|
matches = matches,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = viewportWidth)
|
||||||
|
.then(if (copy == 0) Modifier.onSizeChanged { groupWidth = it.width } else Modifier.clearAndSetSemantics {}),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LiveTickerGroup(matches: List<LiveTickerMatchDto>, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.padding(start = 16.dp, end = 32.dp, top = 9.dp, bottom = 9.dp),
|
||||||
|
) {
|
||||||
|
matches.forEachIndexed { index, match ->
|
||||||
|
if (index > 0) {
|
||||||
|
Text(" • ", color = Color(0xFF64748B))
|
||||||
|
}
|
||||||
|
val status = when {
|
||||||
|
match.completed -> "✓ beendet"
|
||||||
|
match.hasLiveData -> "● LIVE"
|
||||||
|
else -> "◌ Keine Live-Daten"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}",
|
||||||
|
color = when {
|
||||||
|
match.completed -> Color(0xFF64748B)
|
||||||
|
match.hasLiveData -> Color(0xFFB91C1C)
|
||||||
|
else -> Color(0xFF334155)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold,
|
||||||
|
fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,7 +210,10 @@ fun NotificationSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.saveError?.let { message ->
|
state.saveError?.let { message ->
|
||||||
item { Text(message, color = MaterialTheme.colorScheme.error) }
|
item {
|
||||||
|
Text(message, color = MaterialTheme.colorScheme.error)
|
||||||
|
TextButton(onClick = { viewModel.update(state.settings) }) { Text("Erneut speichern") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.error?.let { message ->
|
state.error?.let { message ->
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import de.harheimertc.repositories.PushTokenRepository
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class NotificationSettingsUiState(
|
data class NotificationSettingsUiState(
|
||||||
@@ -37,6 +39,8 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||||
|
private val saveMutex = Mutex()
|
||||||
|
private var settingsRevision = 0L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
@@ -66,14 +70,21 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun update(settings: NotificationPreferences) {
|
fun update(settings: NotificationPreferences) {
|
||||||
|
val revision = ++settingsRevision
|
||||||
preferencesRepository.saveLocal(settings)
|
preferencesRepository.saveLocal(settings)
|
||||||
_state.value = _state.value.copy(settings = settings, saveError = null)
|
_state.value = _state.value.copy(settings = settings, saveError = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.saveRemote(settings)
|
saveMutex.withLock {
|
||||||
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
|
if (revision != settingsRevision) return@withLock
|
||||||
.onFailure { error ->
|
val result = preferencesRepository.saveRemote(settings)
|
||||||
|
if (revision != settingsRevision) return@withLock
|
||||||
|
result.onSuccess { saved ->
|
||||||
|
preferencesRepository.saveLocal(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.")
|
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class LoginViewModelTest {
|
|||||||
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||||
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||||
val vm = LoginViewModel(repo, passkeyRepo)
|
val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true))
|
||||||
|
|
||||||
vm.setEmail("invalid-email")
|
vm.setEmail("invalid-email")
|
||||||
vm.setPassword("")
|
vm.setPassword("")
|
||||||
@@ -57,7 +57,7 @@ class LoginViewModelTest {
|
|||||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||||
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
|
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
|
||||||
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||||
val vm = LoginViewModel(repo, passkeyRepo)
|
val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true))
|
||||||
|
|
||||||
vm.setEmail("max@ex.de")
|
vm.setEmail("max@ex.de")
|
||||||
vm.setPassword("secret")
|
vm.setPassword("secret")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package de.harheimertc.ui.screens.notifications
|
||||||
|
|
||||||
|
import de.harheimertc.data.AuthStatusResponse
|
||||||
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
|
import de.harheimertc.repositories.NotificationPreferences
|
||||||
|
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
|
import de.harheimertc.repositories.requireLiveNotificationSettingsSaved
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class NotificationSettingsViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val preferences = mockk<NotificationPreferencesRepository>(relaxed = true)
|
||||||
|
private val teams = mockk<MannschaftenRepository>()
|
||||||
|
private val login = mockk<LoginRepository>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
every { preferences.loadLocal() } returns NotificationPreferences()
|
||||||
|
coEvery { preferences.loadRemote() } returns Result.success(NotificationPreferences())
|
||||||
|
coEvery { login.status() } returns Result.success(AuthStatusResponse())
|
||||||
|
coEvery { teams.fetchSeasons() } returns Result.failure(IllegalStateException("offline"))
|
||||||
|
coEvery { teams.fetchMannschaften(any()) } returns Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewModel() = NotificationSettingsViewModel(preferences, teams, login, mockk<PushTokenRepository>())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun olderResponseCannotUndoNewerSwitches() = runTest(dispatcher) {
|
||||||
|
val firstResponse = CompletableDeferred<Result<NotificationPreferences>>()
|
||||||
|
val first = NotificationPreferences(liveTickerStarts = true)
|
||||||
|
val latest = first.copy(liveTickerScoreChanges = true, liveTickerFinished = true)
|
||||||
|
coEvery { preferences.saveRemote(first) } coAnswers { firstResponse.await() }
|
||||||
|
coEvery { preferences.saveRemote(latest) } returns Result.success(latest)
|
||||||
|
val vm = viewModel()
|
||||||
|
runCurrent()
|
||||||
|
vm.update(first)
|
||||||
|
runCurrent()
|
||||||
|
vm.update(latest)
|
||||||
|
runCurrent()
|
||||||
|
firstResponse.complete(Result.success(first))
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(latest, vm.state.value.settings)
|
||||||
|
verify(exactly = 1) { preferences.saveLocal(first) }
|
||||||
|
verify(atLeast = 1) { preferences.saveLocal(latest) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedLiveSettingsStayVisibleWithSaveError() = runTest(dispatcher) {
|
||||||
|
val requested = NotificationPreferences(liveTickerFinished = true)
|
||||||
|
coEvery { preferences.saveRemote(requested) } returns Result.failure(IllegalStateException("Backend aktualisieren"))
|
||||||
|
val vm = viewModel()
|
||||||
|
runCurrent()
|
||||||
|
vm.update(requested)
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(vm.state.value.settings.liveTickerFinished)
|
||||||
|
assertEquals("Backend aktualisieren", vm.state.value.saveError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverMustConfirmEachLivePreference() {
|
||||||
|
val defaults = NotificationPreferences()
|
||||||
|
for (requested in listOf(
|
||||||
|
defaults.copy(liveTickerStarts = true),
|
||||||
|
defaults.copy(liveTickerScoreChanges = true),
|
||||||
|
defaults.copy(liveTickerFinished = true),
|
||||||
|
)) {
|
||||||
|
val error = runCatching { requireLiveNotificationSettingsSaved(requested, defaults) }.exceptionOrNull()
|
||||||
|
assertTrue(error?.message.orEmpty().contains("Server"))
|
||||||
|
requireLiveNotificationSettingsSaved(requested, requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=38
|
ANDROID_VERSION_CODE=39
|
||||||
ANDROID_VERSION_NAME=0.11.0
|
ANDROID_VERSION_NAME=0.11.1
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
v-if="isInternal && matches.length"
|
v-if="isInternal && matches.length"
|
||||||
class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
|
class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="mx-auto flex max-w-7xl items-center gap-3 overflow-x-auto px-4 py-2 text-sm sm:px-6">
|
<div class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-2 text-sm sm:px-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
@@ -12,21 +12,48 @@
|
|||||||
>
|
>
|
||||||
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
|
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<button
|
||||||
v-for="match in matches"
|
type="button"
|
||||||
:key="match.meetingId"
|
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']"
|
:aria-pressed="paused"
|
||||||
|
:aria-label="paused ? 'Laufschrift fortsetzen' : 'Laufschrift anhalten'"
|
||||||
|
@click="paused = !paused"
|
||||||
>
|
>
|
||||||
<span v-if="match.completed">✓ beendet</span>
|
{{ paused ? '▶' : 'Ⅱ' }}
|
||||||
<span
|
</button>
|
||||||
v-else-if="match.hasLiveData"
|
<div
|
||||||
class="text-red-700"
|
class="ticker-viewport min-w-0 flex-1"
|
||||||
>● LIVE</span>
|
:class="{ 'ticker-paused': paused }"
|
||||||
<span
|
>
|
||||||
v-else
|
<div
|
||||||
class="text-gray-600"
|
ref="tickerTrack"
|
||||||
>◌ Keine Live-Daten</span>
|
class="ticker-track"
|
||||||
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span>
|
:style="{ '--ticker-duration': `${tickerDuration}s` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="copy in 2"
|
||||||
|
:key="copy"
|
||||||
|
class="ticker-group"
|
||||||
|
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="match in matches"
|
||||||
|
:key="match.meetingId"
|
||||||
|
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']"
|
||||||
|
>
|
||||||
|
<span v-if="match.completed">✓ beendet</span>
|
||||||
|
<span
|
||||||
|
v-else-if="match.hasLiveData"
|
||||||
|
class="text-red-700"
|
||||||
|
>● LIVE</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-gray-600"
|
||||||
|
>◌ Keine Live-Daten</span>
|
||||||
|
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -41,12 +68,26 @@ const route = useRoute()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const matches = ref([])
|
const matches = ref([])
|
||||||
const soundEnabled = ref(false)
|
const soundEnabled = ref(false)
|
||||||
|
const paused = ref(false)
|
||||||
|
const tickerTrack = ref(null)
|
||||||
|
const tickerDuration = ref(30)
|
||||||
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
|
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
|
||||||
let timer
|
let timer
|
||||||
let audioContext
|
let audioContext
|
||||||
let tickerLoaded = false
|
let tickerLoaded = false
|
||||||
let previousStates = new Map()
|
let previousStates = new Map()
|
||||||
|
|
||||||
|
// Two equal copies let the track wrap without a visible jump. ResizeObserver
|
||||||
|
// keeps the speed at 36 px/s when results, fonts or the viewport change.
|
||||||
|
watch(tickerTrack, (track, _previous, onCleanup) => {
|
||||||
|
if (!track) return
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
tickerDuration.value = Math.max(1, track.getBoundingClientRect().width / 2 / 36)
|
||||||
|
})
|
||||||
|
observer.observe(track)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
}, { flush: 'post' })
|
||||||
|
|
||||||
function stateByMeeting(items) {
|
function stateByMeeting(items) {
|
||||||
return new Map(items.map(match => [
|
return new Map(items.map(match => [
|
||||||
match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`,
|
match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`,
|
||||||
@@ -109,3 +150,44 @@ onMounted(() => {
|
|||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => clearInterval(timer))
|
||||||
watch(isInternal, load)
|
watch(isInternal, load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticker-viewport {
|
||||||
|
container-type: inline-size;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-track {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
animation: ticker-scroll var(--ticker-duration) linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-group {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
min-width: 100cqw;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-paused .ticker-track {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.ticker-viewport:hover .ticker-track { animation-play-state: paused; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ticker-scroll {
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ticker-viewport { overflow-x: auto; }
|
||||||
|
.ticker-track { animation: none; will-change: auto; }
|
||||||
|
.ticker-group[aria-hidden="true"] { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
eventsToday: true,
|
eventsToday: true,
|
||||||
|
liveTickerStarts: true,
|
||||||
|
liveTickerScoreChanges: true,
|
||||||
|
liveTickerFinished: true,
|
||||||
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
|
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
|
||||||
selectedTeamSeason: '2025/2026',
|
selectedTeamSeason: '2025/2026',
|
||||||
notificationTime: '07:30'
|
notificationTime: '07:30'
|
||||||
@@ -185,6 +188,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.settings.eventsToday).toBe(true)
|
expect(result.settings.eventsToday).toBe(true)
|
||||||
|
expect(result.settings.liveTickerStarts).toBe(true)
|
||||||
|
expect(result.settings.liveTickerScoreChanges).toBe(true)
|
||||||
|
expect(result.settings.liveTickerFinished).toBe(true)
|
||||||
expect(result.settings.newEvents).toBe(false)
|
expect(result.settings.newEvents).toBe(false)
|
||||||
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
|
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
|
||||||
expect(result.settings.notificationTime).toBe('07:30')
|
expect(result.settings.notificationTime).toBe('07:30')
|
||||||
@@ -202,6 +208,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||||
mockSuccessReadBody({
|
mockSuccessReadBody({
|
||||||
newEvents: true,
|
newEvents: true,
|
||||||
|
liveTickerStarts: true,
|
||||||
|
liveTickerScoreChanges: true,
|
||||||
|
liveTickerFinished: true,
|
||||||
eventsToday: 'true',
|
eventsToday: 'true',
|
||||||
birthdays: true,
|
birthdays: true,
|
||||||
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
|
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
|
||||||
@@ -218,6 +227,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.settings.newEvents).toBe(true)
|
expect(result.settings.newEvents).toBe(true)
|
||||||
|
expect(result.settings.liveTickerStarts).toBe(true)
|
||||||
|
expect(result.settings.liveTickerScoreChanges).toBe(true)
|
||||||
|
expect(result.settings.liveTickerFinished).toBe(true)
|
||||||
expect(result.settings.eventsToday).toBe(false)
|
expect(result.settings.eventsToday).toBe(false)
|
||||||
expect(result.settings.birthdays).toBe(true)
|
expect(result.settings.birthdays).toBe(true)
|
||||||
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
|
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
|
||||||
@@ -227,6 +239,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
notificationSettings: expect.objectContaining({
|
notificationSettings: expect.objectContaining({
|
||||||
newEvents: true,
|
newEvents: true,
|
||||||
|
liveTickerStarts: true,
|
||||||
|
liveTickerScoreChanges: true,
|
||||||
|
liveTickerFinished: true,
|
||||||
birthdays: true,
|
birthdays: true,
|
||||||
selectedTeamSeason: '2026/2027'
|
selectedTeamSeason: '2026/2027'
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user