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 353a9ea..f4f558f 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 @@ -78,9 +78,15 @@ class EventDetailViewModel @Inject constructor( * the occurrence's own times (from `CalendarContract.Instances`); they * override the series DTSTART/DTEND so recurring events show the correct * date instead of the first occurrence. + * + * Re-opening the *same* occurrence always re-reads it. The view model + * outlives the sheet, and a `StateFlow` conflates an identical value away, + * so assigning the target alone would leave an edit that changed no time — + * adding a description, say (#196) — showing the state from before the save. */ fun open(eventId: Long, beginMillis: Long, endMillis: Long) { - _target.value = Target(eventId, beginMillis, endMillis) + val target = Target(eventId, beginMillis, endMillis) + if (_target.value == target) _reload.value += 1 else _target.value = target } /** Re-run the current load after a failure. */ 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 new file mode 100644 index 0000000..271568e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt @@ -0,0 +1,103 @@ +package de.jeanlucmakiola.calendula.ui.detail + +import android.content.ContextWrapper +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +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. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EventDetailViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) + @AfterEach fun tearDown() = Dispatchers.resetMain() + + private val beginMillis = 1_781_164_800_000L + private val endMillis = beginMillis + 3_600_000L + + private fun detail(description: String?) = EventDetail( + instance = EventInstance( + instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup", + start = Instant.fromEpochMilliseconds(beginMillis), + end = Instant.fromEpochMilliseconds(endMillis), + isAllDay = false, color = 0xFF000000.toInt(), location = null, + ), + description = description, organizer = null, attendees = emptyList(), rrule = null, + ) + + private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel { + val prefs = CalendarPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() }, + ), + ) + val settings = SettingsPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() }, + ), + ) + val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher) + // Only `shareUri()` touches the exporter, and nothing here shares. + return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher) + } + + @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 collector = launch(Job()) { vm.state.collect {} } + + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat((vm.state.value as EventDetailUiState.Success).detail.description).isNull() + + // The edit screen saved a description; the tapped occurrence is unchanged. + stored = "Bring the roadmap" + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat((vm.state.value as EventDetailUiState.Success).detail.description) + .isEqualTo("Bring the roadmap") + + collector.cancel() + } +}