From a5b38aec9ea420e163c3d168feceebd9a4ae2d8c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 17 Aug 2026 21:32:29 +0200 Subject: [PATCH] Keep the loaded detail on screen while it re-reads (#196) --- .../ui/detail/EventDetailViewModel.kt | 14 +++++-- .../ui/detail/EventDetailViewModelTest.kt | 41 ++++++++++++++----- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index f4f558f..cd068cb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor( ) : ViewModel() { private val _target = MutableStateFlow(null) - // Bumped by retry() to re-run the load for the same target. + // Bumped by retry() and by re-opening the target already shown, to re-run + // the load without changing the target. private val _reload = MutableStateFlow(0) + // Last target whose content is already on screen; a re-read of it skips the + // Loading skeleton so the sheet doesn't blank out between two identical reads. + private var loadedTarget: Target? = null + private val _deleteState = MutableStateFlow(DeleteUiState.Idle) val deleteState: StateFlow = _deleteState.asStateFlow() @@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor( combine(_target, _reload) { target, _ -> target } .flatMapLatest { target -> if (target == null) { + loadedTarget = null flowOf(EventDetailUiState.Loading) } else { flow { - emit(EventDetailUiState.Loading) - emit(loadDetail(target)) + if (loadedTarget != target) emit(EventDetailUiState.Loading) + val loaded = loadDetail(target) + loadedTarget = target.takeIf { loaded is EventDetailUiState.Success } + emit(loaded) } } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt index 271568e..2a3ae75 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt @@ -32,6 +32,8 @@ import kotlin.time.Instant /** * Re-opening an occurrence must re-read it (#196): the view model outlives the * sheet, so an edit that changed no time would otherwise show the pre-save row. + * The re-read stays silent — the loaded content must not blink back to the + * skeleton on the way. */ @OptIn(ExperimentalCoroutinesApi::class) class EventDetailViewModelTest { @@ -72,19 +74,20 @@ class EventDetailViewModelTest { return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher) } + private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply { + calendarsResult = listOf( + CalendarSource( + id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL", + color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true, + ), + ) + eventDetailResult = { detail(description()) } + } + @Test fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) { var stored: String? = null - val fake = FakeCalendarDataSource().apply { - calendarsResult = listOf( - CalendarSource( - id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL", - color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true, - ), - ) - eventDetailResult = { detail(stored) } - } - val vm = viewModel(tempDir, fake) + val vm = viewModel(tempDir, fakeSource { stored }) val collector = launch(Job()) { vm.state.collect {} } vm.open(42L, beginMillis, endMillis) @@ -100,4 +103,22 @@ class EventDetailViewModelTest { collector.cancel() } + + @Test + fun `the re-read does not fall back to the skeleton`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir, fakeSource { null }) + val seen = mutableListOf() + val collector = launch(Job()) { vm.state.collect { seen += it } } + + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat(vm.state.value).isInstanceOf(EventDetailUiState.Success::class.java) + + seen.clear() + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat(seen).doesNotContain(EventDetailUiState.Loading) + + collector.cancel() + } }