fix: animiere Live-Ticker und stabilisiere Benachrichtigungsschalter
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user