Keep the edit form's results from landing on the next one (#178) (#397)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped

Follow-up to #394, from two review passes on the tap-opens-editor change.

- The edit form's view model is reused between opens, so work still running for a closed form could land on the next one. Backing out of a slow load and tapping another event showed the old event, and Save wrote to it. A delete finishing after back closed the next editor. Results from a closed form are now dropped.
- Back is ignored while a save or delete runs. The write can't be called back, so the form waits for the result and then closes or reports the error.
- Unsaved changes are now measured against the form as it opened, defaults included, instead of a flag every setter had to set.
- Taps read the setting and whether the calendar takes writes when they happen, so a tap right after a cold start or a return to the app opens the right screen.
- Without write access, the first tap asks once. A refusal opens the details from then on, until the permission is granted.
- Pinning an event to the tapped occurrence lives in one helper (`EventDetail.atOccurrence`), used by the detail, edit and selection-bar paths.

Closes #178

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/397
This commit is contained in:
Jean-Luc Makiola
2026-10-14 17:49:53 +02:00
co-authored by makiolaj
parent cf3c4e96cd
commit 0121aa5b1d
9 changed files with 211 additions and 90 deletions
@@ -165,6 +165,17 @@ data class EventDetail(
val isException: Boolean = false,
)
/**
* This event on one occurrence's times. The Events row holds the series start,
* so a recurring event needs the tapped occurrence pinned to render and share right.
*/
fun EventDetail.atOccurrence(beginMillis: Long, endMillis: Long): EventDetail = copy(
instance = instance.copy(
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
),
)
/**
* One selectable event colour published by a calendar's account
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
@@ -30,6 +30,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
@@ -61,6 +62,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -456,15 +458,25 @@ fun CalendarHost(
editKey = key
}
// A tap opens the edit form instead of the detail screen when the user chose
// so (#178) — only for events whose calendar takes writes.
val tapOpensEditor = viewModel.tapOpensEditor.collectAsStateWithLifecycle().value
val actionCalendars by eventActions.calendars.collectAsStateWithLifecycle()
// so (#178) — only for events whose calendar takes writes. Both are read at
// tap time, so a tap before they've loaded still routes right.
val tapScope = rememberCoroutineScope()
val tapContext = LocalContext.current
val requireWriteOr = rememberWriteGateOr()
// A refused write upgrade is asked once; later taps just open the details.
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
val onEventClick: (EventInstance) -> Unit = { event ->
if (tapOpensEditor && actionCalendars[event.calendarId]?.canModifyContents == true) {
requireWriteOr({ onEditEvent(event) }, { onOpenDetail(event) })
} else {
onOpenDetail(event)
tapScope.launch {
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
when {
!toEditor -> onOpenDetail(event)
tapContext.canWriteCalendar() -> onEditEvent(event)
tapWriteRefused -> onOpenDetail(event)
else -> requireWriteOr({ onEditEvent(event) }) {
tapWriteRefused = true
onOpenDetail(event)
}
}
}
}
// Off by preference (#173) switches off the gesture, not the scope: blocks
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate
import javax.inject.Inject
@@ -79,11 +80,8 @@ class CalendarHostViewModel @Inject constructor(
initialValue = false,
)
private val tapOpensEditorPref = prefs.tapOpensEditor
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
val tapOpensEditor: StateFlow<Boolean> = prefs.tapOpensEditor
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
suspend fun tapOpensEditor(): Boolean = tapOpensEditorPref.first()
}
@@ -73,6 +73,11 @@ suspend fun CalendarRepository.deleteEvent(
/** How a [tryDeleteEvent] ended. */
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
fun Context.canWriteCalendar(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
suspend fun CalendarRepository.tryDeleteEvent(
eventId: Long,
@@ -115,11 +120,7 @@ fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
pending = null
}
return { action, fallback ->
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
if (context.canWriteCalendar()) {
action()
} else {
pending = action to fallback
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -109,10 +111,22 @@ class EventActionsViewModel @Inject constructor(
_failure.value = null
}
/** Whether [calendarId] takes writes, asking the provider while [calendars] hasn't loaded yet. */
suspend fun canModify(calendarId: Long): Boolean {
val calendar = calendars.value[calendarId] ?: try {
repository.calendars().first().firstOrNull { it.id == calendarId }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
return calendar?.canModifyContents == true
}
private suspend fun load(event: EventInstance): EventDetail? = try {
val detail = repository.eventDetail(event.eventId)
// The row holds the series start; the actions work on this occurrence.
detail.copy(instance = detail.instance.copy(start = event.start, end = event.end))
repository.eventDetail(event.eventId)
.atOccurrence(event.start.toEpochMilliseconds(), event.end.toEpochMilliseconds())
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.descriptionRichText
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
@@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
/**
@@ -113,8 +113,8 @@ class EventDetailViewModel @Inject constructor(
if (_deleteState.value == DeleteUiState.Deleting) return
viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting
// Already gone is what a delete wants anyway.
_deleteState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
// Already gone is what a delete wants anyway.
DeleteOutcome.Deleted, DeleteOutcome.Gone -> DeleteUiState.Deleted
DeleteOutcome.NeedsPermission -> DeleteUiState.NeedsPermission
DeleteOutcome.Failed -> DeleteUiState.Failed
@@ -148,20 +148,13 @@ class EventDetailViewModel @Inject constructor(
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
// occurrence's time so recurring events render correctly. An external
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
// own DTSTART/DTEND instead of overriding it to the epoch.
// An external "open event" that names no occurrence ([NO_OCCURRENCE_TIME] —
// e.g. a bare content://.../events/<id> VIEW intent, issue #48) keeps the
// row's own DTSTART/DTEND instead of overriding it to the epoch.
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
detail
} else {
detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
detail.atOccurrence(target.beginMillis, target.endMillis)
}
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
@@ -4,7 +4,6 @@ import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns
@@ -38,6 +37,7 @@ import androidx.compose.material.icons.filled.Add
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import androidx.compose.runtime.rememberCoroutineScope
@@ -115,7 +115,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@@ -268,8 +267,14 @@ fun EventEditScreen(
val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsStateWithLifecycle()
var showLeavePrompt by rememberSaveable { mutableStateOf(false) }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// A running save or delete can't be called back, so leaving waits for it;
// its result then closes the form or reports why it didn't.
val requestClose = {
if (hasUnsavedChanges) showLeavePrompt = true else close()
when {
state?.saveState == SaveUiState.Saving -> Unit
hasUnsavedChanges -> showLeavePrompt = true
else -> close()
}
}
// Something else wants this form's place, e.g. a second share (#306).
LaunchedEffect(leaveRequested) {
@@ -287,26 +292,22 @@ fun EventEditScreen(
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Contextual WRITE_CALENDAR upgrade for v1.0 installs, like delete.
val writePermissionLauncher = rememberLauncherForActivityResult(
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
// Save keeps its own launcher: its result calls straight into the ViewModel,
// so a save waiting on the permission dialog survives a rotation.
val savePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) viewModel.save()
}
val onSaveClick = {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
if (context.canWriteCalendar()) {
viewModel.save()
} else {
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
savePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val shareFailedMessage = stringResource(R.string.event_share_failed)
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
val onShareClick: () -> Unit = {
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.domain.withTimes
import de.jeanlucmakiola.calendula.domain.withTimezone
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
@@ -178,9 +179,11 @@ class EventEditViewModel @Inject constructor(
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
// Set by the first change the user makes, so a form only holding its own
// defaults or an import's prefill can be left without asking.
private val _edited = MutableStateFlow(false)
// The form as the user received it: the loaded event, or a new form with its
// prefill and auto-applied defaults. Anything else is an unsaved change.
private val _baseline = MutableStateFlow<EventForm?>(null)
// Bumped by reset(), so work started for a closed form never lands on the next.
private var session = 0
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
@@ -343,15 +346,11 @@ class EventEditViewModel @Inject constructor(
)
/**
* Whether leaving now would lose something the user did (#178): an edit that
* differs from the loaded event, or any change to a new or imported form.
* Whether leaving now would lose something the user did (#178): the form
* differs from how it opened, counting the defaults it applied itself.
*/
val hasUnsavedChanges: StateFlow<Boolean> = combine(_form, _editTarget, _edited) { form, target, edited ->
when {
form == null -> false
target != null -> form != target.original
else -> edited
}
val hasUnsavedChanges: StateFlow<Boolean> = combine(_form, _baseline) { form, baseline ->
form != null && form != baseline
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
@@ -381,7 +380,7 @@ class EventEditViewModel @Inject constructor(
else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
}
val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes
_form.value = EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone))
open(EventForm(calendarId = null, start = startAt.toFormTime(zone), end = endAt.toFormTime(zone)))
applyDefaultReminder()
applyDefaultDuration()
}
@@ -396,7 +395,7 @@ class EventEditViewModel @Inject constructor(
fun openDraft(start: Instant, end: Instant) {
if (_form.value != null) return
val zone = zoneSource.current()
_form.value = EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone))
open(EventForm(calendarId = null, start = start.toFormTime(zone), end = end.toFormTime(zone)))
_durationTouched.value = true
applyDefaultReminder()
applyDefaultDuration()
@@ -430,7 +429,7 @@ class EventEditViewModel @Inject constructor(
fun openImported(form: EventForm, source: ImportSource) {
if (_form.value != null || _editTarget.value != null) return
_revealed.value = form.populatedFields()
_form.value = form
open(form)
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert, ImportSource.InsertOpenEnded ->
@@ -455,16 +454,18 @@ class EventEditViewModel @Inject constructor(
*/
private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return
val opened = session
viewModelScope.launch {
val defaults = reminderDefaults()
val targetId = calendarId ?: resolvedCalendarId.first()
// Re-check after suspending: bail if the form closed or the user edited.
if (opened != session) return@launch
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
// Write through update(): the duration default resolves in parallel,
// so both must compose onto the current form, not a pre-suspend copy.
update { it.copy(reminders = reminders) }
// The duration default resolves in parallel, so both must compose
// onto the current form, not a pre-suspend copy.
applyDefault { it.copy(reminders = reminders) }
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) {
@@ -493,6 +494,7 @@ class EventEditViewModel @Inject constructor(
*/
private fun applyDefaultDuration(calendarId: Long? = null) {
if (_editTarget.value != null || _durationTouched.value) return
val opened = session
viewModelScope.launch {
val global = settingsPrefs.defaultEventDurationMinutes.first()
val overrides = settingsPrefs.perCalendarEventDuration.first()
@@ -504,10 +506,11 @@ class EventEditViewModel @Inject constructor(
else -> calendarId ?: resolvedCalendarId.first()
}
// Re-check after suspending: bail if the form closed or the user edited.
if (opened != session) return@launch
if (_editTarget.value != null || _durationTouched.value) return@launch
if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId)
update { form ->
applyDefault { form ->
val zone = form.resolvedZone(zoneSource.current())
val start = form.startInstant(zone)
form.withTimes(start, start + duration.minutes, zone)
@@ -523,10 +526,12 @@ class EventEditViewModel @Inject constructor(
* configured and it isn't already exactly what the file carried.
*/
private fun maybePromptImportedReminderDefault(form: EventForm) {
val opened = session
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
// Bail if the form closed or became an edit while we resolved.
if (opened != session) return@launch
val current = _form.value ?: return@launch
if (_editTarget.value != null) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
@@ -562,37 +567,35 @@ class EventEditViewModel @Inject constructor(
*/
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return
val opened = session
viewModelScope.launch {
val detail = try {
repository.eventDetail(eventId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_loadFailed.value = true
if (opened == session) _loadFailed.value = true
return@launch
}
if (opened != session) return@launch
val zone = zoneSource.current()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
val occurrence = detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(begin),
end = Instant.fromEpochMilliseconds(end),
),
)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, occurrence)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end))
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
open(snapshot.form)
}
}
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() {
session++
_form.value = null
_baseline.value = null
_saveState.value = SaveUiState.Idle
_showProblems.value = false
_revealed.value = emptySet()
@@ -601,7 +604,6 @@ class EventEditViewModel @Inject constructor(
_remindersTouched.value = false
_durationTouched.value = false
_importReminderPrompt.value = null
_edited.value = false
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -789,8 +791,13 @@ class EventEditViewModel @Inject constructor(
ignoreConflict: Boolean = false,
) {
val target = _editTarget.value
val opened = session
// A form closed mid-save drops the result rather than handing it to the next one.
fun settle(result: SaveUiState) {
if (opened == session) _saveState.value = result
}
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
settle(SaveUiState.Saving)
// No locking (plan 03, decision 5): right before writing, re-read
// the event and compare against what the form loaded. An external
// change parks the save in a conflict dialog instead of silently
@@ -802,18 +809,18 @@ class EventEditViewModel @Inject constructor(
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_saveState.value = SaveUiState.Gone
settle(SaveUiState.Gone)
return@launch
} catch (e: Exception) {
// Can't verify — proceed; a real problem fails the write itself.
null
}
if (fresh != null && fresh != target.snapshot) {
_saveState.value = SaveUiState.AwaitingConflict(scope)
settle(SaveUiState.AwaitingConflict(scope))
return@launch
}
}
_saveState.value = try {
val result = try {
if (target == null) {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
@@ -864,6 +871,7 @@ class EventEditViewModel @Inject constructor(
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed
}
settle(result)
}
}
@@ -883,10 +891,13 @@ class EventEditViewModel @Inject constructor(
fun delete(scope: RecurringWriteScope) {
val target = _editTarget.value ?: return
if (_saveState.value == SaveUiState.Saving) return
val opened = session
_saveState.value = SaveUiState.Saving
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
// Already gone is what a delete wants anyway.
_saveState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
val outcome = repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)
if (opened != session) return@launch
_saveState.value = when (outcome) {
// Already gone is what a delete wants anyway.
DeleteOutcome.Deleted, DeleteOutcome.Gone -> SaveUiState.Deleted
DeleteOutcome.NeedsPermission -> SaveUiState.DeleteDenied
DeleteOutcome.Failed -> SaveUiState.DeleteFailed
@@ -914,15 +925,19 @@ class EventEditViewModel @Inject constructor(
private fun opened(form: EventForm): EventForm? =
_editTarget.value?.original?.takeIf { it.timezone == form.timezone && !it.isAllDay }
private inline fun update(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
private fun open(form: EventForm) {
_form.value = form
_baseline.value = form
}
/** A change the user made, as opposed to a default the form applied itself. */
/** A default the form applied itself; it moves the baseline too, so it's no unsaved change. */
private inline fun applyDefault(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
_baseline.value = _baseline.value?.let(block)
}
/** A change the user made. */
private inline fun edit(block: (EventForm) -> EventForm) {
val current = _form.value ?: return
val next = block(current)
if (next != current) _edited.value = true
_form.value = next
_form.value = _form.value?.let(block)
}
}
@@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -108,9 +109,12 @@ class EventEditViewModelTest {
// DataStore on the same file would clash, so it is built only once.
s: SettingsPrefs = settings(tempDir),
zones: FakeZoneSource = FakeZoneSource(),
// A queued dispatcher here holds provider reads and writes until the test
// advances, so it can close the form while one is in flight.
io: CoroutineDispatcher = dispatcher,
): EventEditViewModel {
val p = prefs(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
val repo = CalendarRepositoryImpl(fake, p, s, io)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher, zones)
}
@@ -465,6 +469,10 @@ class EventEditViewModelTest {
vm.setTitle("Lunch")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isTrue()
vm.setTitle("")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
job.cancel()
}
@@ -658,4 +666,72 @@ class EventEditViewModelTest {
assertThat(vm.state.value!!.form).isEqualTo(opened)
job.cancel()
}
@Test
fun `a load abandoned by closing the form never lands on the next one`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { id ->
val base = detail(calendarId = 1L)
base.copy(instance = base.instance.copy(eventId = id, title = "Event $id"))
}
}
val vm = viewModel(tempDir, fake, io = StandardTestDispatcher(testScheduler))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = beginMillis, endMillis = endMillis)
vm.reset()
advanceUntilIdle()
vm.openForEdit(eventId = 2L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.form?.title).isEqualTo("Event 2")
job.cancel()
}
@Test
fun `a failed load abandoned by closing the form doesn't close the next one`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { null }
}
val vm = viewModel(tempDir, fake, io = StandardTestDispatcher(testScheduler))
val job = activate(vm)
vm.openForEdit(eventId = 1L, beginMillis = beginMillis, endMillis = endMillis)
vm.reset()
advanceUntilIdle()
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
advanceUntilIdle()
assertThat(vm.loadFailed.value).isFalse()
job.cancel()
}
@Test
fun `a delete still running when the form closes doesn't report to the next one`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake, io = StandardTestDispatcher(testScheduler))
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
vm.delete(RecurringWriteScope.AllEvents)
vm.reset()
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(42L)
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Idle)
job.cancel()
}
}