From 4ecc2cc90cc27de7e26e934154f927f5a02aa92a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 29 Oct 2026 13:52:05 +0100 Subject: [PATCH] fix: animiere Live-Ticker und stabilisiere Benachrichtigungsschalter --- .../NotificationPreferencesRepository.kt | 12 +- .../ui/components/LiveTickerBanner.kt | 111 +++++++++++++---- .../NotificationSettingsScreen.kt | 5 +- .../NotificationSettingsViewModel.kt | 17 ++- .../ui/screens/login/LoginViewModelTest.kt | 4 +- .../NotificationSettingsViewModelTest.kt | 96 +++++++++++++++ android-app/gradle.properties | 4 +- components/LiveTickerBanner.vue | 112 +++++++++++++++--- tests/config-profile-endpoints.spec.ts | 15 +++ 9 files changed, 325 insertions(+), 51 deletions(-) create mode 100644 android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt 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 9656b2e..22d4b18 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 @@ -88,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor( } suspend fun saveRemote(settings: NotificationPreferences): Result { - 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, 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 index 0243fe7..b6d2ec2 100644 --- 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 @@ -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, loaded: Boolean) { @@ -45,34 +60,78 @@ fun LiveTickerBanner(matches: List, 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, 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, + ) + } + } +} 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 f4a3f99..4942a5f 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 @@ -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 -> diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt index 906a22f..f91a802 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt @@ -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 = _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.") } + } } } diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt index a2412e6..536e5ae 100755 --- a/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt @@ -38,7 +38,7 @@ class LoginViewModelTest { val repo = mockk() val passkeyRepo = mockk() 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() 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") diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt new file mode 100644 index 0000000..aca748f --- /dev/null +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt @@ -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(relaxed = true) + private val teams = mockk() + private val login = mockk() + + @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()) + + @Test + fun olderResponseCannotUndoNewerSwitches() = runTest(dispatcher) { + val firstResponse = CompletableDeferred>() + 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) + } + } +} diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 9b70bac..f29737d 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=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 diff --git a/components/LiveTickerBanner.vue b/components/LiveTickerBanner.vue index d4ba933..c9b62bb 100644 --- a/components/LiveTickerBanner.vue +++ b/components/LiveTickerBanner.vue @@ -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" > -
+
-
- ✓ beendet - ● LIVE - ◌ Keine Live-Daten - {{ match.homeTeam }} {{ match.score }} {{ match.awayTeam }} + {{ paused ? '▶' : 'Ⅱ' }} + +
+
+
+
+ ✓ beendet + ● LIVE + ◌ Keine Live-Daten + {{ match.homeTeam }} {{ match.score }} {{ match.awayTeam }} +
+
+
@@ -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) + + diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts index d0d2446..ec3c6b7 100755 --- a/tests/config-profile-endpoints.spec.ts +++ b/tests/config-profile-endpoints.spec.ts @@ -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' })