dev #59
@@ -88,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
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)
|
||||
requireLiveNotificationSettingsSaved(settings, 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(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
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.fillMaxWidth
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.runtime.withFrameNanos
|
||||
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.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.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@Composable
|
||||
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||
@@ -45,34 +60,78 @@ fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||
|
||||
if (matches.isEmpty()) return
|
||||
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
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))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = { paused = !paused },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = if (paused) "Laufschrift fortsetzen" else "Laufschrift anhalten"
|
||||
},
|
||||
) {
|
||||
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 {
|
||||
match.completed -> "✓ beendet"
|
||||
match.hasLiveData -> "● LIVE"
|
||||
else -> "◌ Keine Live-Daten"
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||
.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 ->
|
||||
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 ->
|
||||
|
||||
@@ -12,6 +12,8 @@ import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NotificationSettingsUiState(
|
||||
@@ -37,6 +39,8 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||
private val saveMutex = Mutex()
|
||||
private var settingsRevision = 0L
|
||||
|
||||
init {
|
||||
load()
|
||||
@@ -66,14 +70,21 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun update(settings: NotificationPreferences) {
|
||||
val revision = ++settingsRevision
|
||||
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 ->
|
||||
saveMutex.withLock {
|
||||
if (revision != settingsRevision) return@withLock
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class LoginViewModelTest {
|
||||
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||
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.setPassword("")
|
||||
@@ -57,7 +57,7 @@ class LoginViewModelTest {
|
||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
|
||||
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.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/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=38
|
||||
ANDROID_VERSION_NAME=0.11.0
|
||||
ANDROID_VERSION_CODE=39
|
||||
ANDROID_VERSION_NAME=0.11.1
|
||||
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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"
|
||||
>
|
||||
<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
|
||||
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"
|
||||
@@ -12,21 +12,48 @@
|
||||
>
|
||||
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
|
||||
</button>
|
||||
<div
|
||||
v-for="match in matches"
|
||||
:key="match.meetingId"
|
||||
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']"
|
||||
<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"
|
||||
:aria-pressed="paused"
|
||||
:aria-label="paused ? 'Laufschrift fortsetzen' : 'Laufschrift anhalten'"
|
||||
@click="paused = !paused"
|
||||
>
|
||||
<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>
|
||||
{{ paused ? '▶' : 'Ⅱ' }}
|
||||
</button>
|
||||
<div
|
||||
class="ticker-viewport min-w-0 flex-1"
|
||||
:class="{ 'ticker-paused': paused }"
|
||||
>
|
||||
<div
|
||||
ref="tickerTrack"
|
||||
class="ticker-track"
|
||||
: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>
|
||||
</section>
|
||||
@@ -41,12 +68,26 @@ const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const matches = ref([])
|
||||
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')))
|
||||
let timer
|
||||
let audioContext
|
||||
let tickerLoaded = false
|
||||
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) {
|
||||
return new Map(items.map(match => [
|
||||
match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`,
|
||||
@@ -109,3 +150,44 @@ onMounted(() => {
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
watch(isInternal, load)
|
||||
</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',
|
||||
notificationSettings: {
|
||||
eventsToday: true,
|
||||
liveTickerStarts: true,
|
||||
liveTickerScoreChanges: true,
|
||||
liveTickerFinished: true,
|
||||
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
|
||||
selectedTeamSeason: '2025/2026',
|
||||
notificationTime: '07:30'
|
||||
@@ -185,6 +188,9 @@ describe('Config & Profil Endpoints', () => {
|
||||
|
||||
expect(result.success).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.selectedTeamSlugs).toEqual(['herren-1'])
|
||||
expect(result.settings.notificationTime).toBe('07:30')
|
||||
@@ -202,6 +208,9 @@ describe('Config & Profil Endpoints', () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
mockSuccessReadBody({
|
||||
newEvents: true,
|
||||
liveTickerStarts: true,
|
||||
liveTickerScoreChanges: true,
|
||||
liveTickerFinished: true,
|
||||
eventsToday: 'true',
|
||||
birthdays: true,
|
||||
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
|
||||
@@ -218,6 +227,9 @@ describe('Config & Profil Endpoints', () => {
|
||||
|
||||
expect(result.success).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.birthdays).toBe(true)
|
||||
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
|
||||
@@ -227,6 +239,9 @@ describe('Config & Profil Endpoints', () => {
|
||||
id: '1',
|
||||
notificationSettings: expect.objectContaining({
|
||||
newEvents: true,
|
||||
liveTickerStarts: true,
|
||||
liveTickerScoreChanges: true,
|
||||
liveTickerFinished: true,
|
||||
birthdays: true,
|
||||
selectedTeamSeason: '2026/2027'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user