feat(edit): conflict dialog on save + store metadata refresh (v2.0)

No locking (plan 03, decision 5): openForEdit keeps an EditSnapshot — the
prefilled form plus the raw Events-row times, which the form itself can't
see (it derives its times from the tapped occurrence, so an externally
moved event would otherwise stay invisible). Right before writing,
performSave re-reads the event and compares snapshots: a mismatch parks
the save in SaveUiState.AwaitingConflict carrying the already-chosen
recurring scope, and the dialog offers overwrite / discard / cancel
(OptionCard style). Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. A deleted event lands in
SaveUiState.Gone — an informational dialog that closes form and detail.
Fields the form can't write (attendees, status, self response, reminder
methods) are excluded from the comparison so sync noise can't fake a
conflict. The load-time zone is pinned in the EditTarget so a device
timezone change mid-edit can't either.

Store metadata: F-Droid descriptions (DE+EN) and the README stop claiming
read-only and now describe write support and reminder delivery. New
fastlane phoneScreenshots (6 per locale: week/month/day/detail/form/
reminder onboarding), captured on-device against demo-only calendars.

Tests: EditSnapshot equality (unchanged event, field change, row-time move
the form can't see, non-writable changes stay quiet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:14:27 +02:00
parent 264b2a86c1
commit 626623bb6e
25 changed files with 291 additions and 38 deletions

View File

@@ -94,6 +94,30 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
)
}
/**
* What the edit form saw when it loaded — compared against a fresh read at
* save time to detect external changes (sync, another device) that landed
* while the form was open. The raw row times ride along because
* [toEditForm] derives the form's times from the *tapped occurrence*, so
* re-deriving with the same occurrence would mask an externally moved
* event. Not covered (the form can't write them, and the dirty-checked
* write can't clobber them): attendees, status, the user's own response,
* reminder methods, and a recurring event's duration.
*/
data class EditSnapshot(
val form: EventForm,
/** The raw Events-row times (for recurring events: the series anchor). */
val rowStart: Instant,
val rowEnd: Instant,
)
fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZone): EditSnapshot =
EditSnapshot(
form = toEditForm(beginMillis, endMillis, zone),
rowStart = instance.start,
rowEnd = instance.end,
)
/**
* The optional sections that hold a value in [form] — when editing, these
* must be visible regardless of the user's default-fields setting, or the

View File

@@ -217,7 +217,8 @@ fun EventEditScreen(
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(writeDeniedMessage)
}
SaveUiState.Idle, SaveUiState.AwaitingScope, SaveUiState.Saving, null -> Unit
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
else -> Unit
}
}
@@ -269,6 +270,68 @@ fun EventEditScreen(
onDismiss = viewModel::consumeSaveResult,
)
}
// The event changed externally (sync) while the form was open (v2.0).
if (state?.saveState is SaveUiState.AwaitingConflict) {
SaveConflictDialog(
onOverwrite = viewModel::saveOverwriting,
onDiscard = close,
onDismiss = viewModel::consumeSaveResult,
)
}
// ...or was deleted underneath us — nothing left to save onto. Closing
// through [onSaved] also pops the detail screen, whose occurrence is gone.
if (state?.saveState == SaveUiState.Gone) {
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.event_edit_gone_title)) },
text = { Text(stringResource(R.string.event_edit_gone_body)) },
confirmButton = {
TextButton(onClick = {
viewModel.reset()
onSaved()
}) { Text(stringResource(R.string.dialog_ok)) }
},
)
}
}
/**
* Overwrite-or-discard choice when the event changed underneath an open
* form (no locking; detected by re-reading at save time). "Overwrite" still
* only writes the fields the user edited — external changes to untouched
* fields survive either way. Cancelling returns to the form.
*/
@Composable
private fun SaveConflictDialog(
onOverwrite: () -> Unit,
onDiscard: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.event_edit_conflict_body))
Spacer(Modifier.height(4.dp))
OptionCard(
label = stringResource(R.string.event_edit_conflict_overwrite),
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
onClick = onOverwrite,
)
OptionCard(
label = stringResource(R.string.event_edit_conflict_discard),
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
onClick = onDiscard,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/**

View File

@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
/**
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
@@ -39,6 +40,14 @@ sealed interface SaveUiState {
data object Idle : SaveUiState
/** A dirty recurring event waits for the user to pick the write scope. */
data object AwaitingScope : SaveUiState
/**
* The event changed externally (sync) while the form was open; the save
* is parked with its chosen [scope] until the user picks overwrite,
* discard, or cancel.
*/
data class AwaitingConflict(val scope: RecurringWriteScope) : SaveUiState
/** The event was deleted externally while the form was open. */
data object Gone : SaveUiState
data object Saving : SaveUiState
data object Saved : SaveUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */

View File

@@ -4,18 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -68,15 +70,21 @@ class EventEditViewModel @Inject constructor(
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
/**
* The event being edited plus the form exactly as it was prefilled.
* The event being edited plus everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the
* tapped occurrence's [beginMillis] anchors occurrence-level writes.
* tapped occurrence's [beginMillis]/[endMillis] anchor occurrence-level
* writes and the conflict re-read. [zone] is pinned at load so a device
* timezone change mid-edit can't fake a conflict.
*/
private data class EditTarget(
val eventId: Long,
val original: EventForm,
val snapshot: EditSnapshot,
val beginMillis: Long,
)
val endMillis: Long,
val zone: TimeZone,
) {
val original: EventForm get() = snapshot.form
}
private data class LocalInputs(
val form: EventForm?,
@@ -167,11 +175,12 @@ class EventEditViewModel @Inject constructor(
_loadFailed.value = true
return@launch
}
val original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault())
_editTarget.value = EditTarget(eventId, original, beginMillis)
val zone = TimeZone.currentSystemDefault()
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
// Sections holding data must show even when not in the defaults.
_revealed.value = original.populatedFields()
_form.value = original
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
}
}
@@ -249,10 +258,43 @@ class EventEditViewModel @Inject constructor(
performSave(current.form, scope)
}
private fun performSave(form: EventForm, scope: RecurringWriteScope) {
/** Finish a save parked in [SaveUiState.AwaitingConflict], overwriting. */
fun saveOverwriting() {
val current = state.value ?: return
val parked = current.saveState as? SaveUiState.AwaitingConflict ?: return
performSave(current.form, parked.scope, ignoreConflict = true)
}
private fun performSave(
form: EventForm,
scope: RecurringWriteScope,
ignoreConflict: Boolean = false,
) {
val target = _editTarget.value
viewModelScope.launch {
_saveState.value = 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
// clobbering the edited fields.
if (target != null && !ignoreConflict) {
val fresh = try {
repository.eventDetail(target.eventId)
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_saveState.value = 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)
return@launch
}
}
_saveState.value = try {
if (target == null) {
repository.createEvent(form)

View File

@@ -82,6 +82,16 @@
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
<string name="event_edit_conflict_overwrite">Meine Änderungen speichern</string>
<string name="event_edit_conflict_overwrite_hint">Nur von dir bearbeitete Felder überschreiben die externe Änderung</string>
<string name="event_edit_conflict_discard">Meine Änderungen verwerfen</string>
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>

View File

@@ -83,6 +83,16 @@
<string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string>
<!-- Event form — save conflict (v2.0) -->
<string name="event_edit_conflict_title">Event changed elsewhere</string>
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
<string name="event_edit_conflict_overwrite">Save my changes</string>
<string name="event_edit_conflict_overwrite_hint">Only fields you edited overwrite the outside change</string>
<string name="event_edit_conflict_discard">Discard my changes</string>
<string name="event_edit_conflict_discard_hint">The event stays as it is now</string>
<string name="event_edit_gone_title">Event deleted</string>
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
<!-- Event form — recurrence picker (v1.3) -->
<string name="event_edit_recurrence_none">Does not repeat</string>
<string name="event_edit_recurrence_custom">Custom</string>

View File

@@ -112,21 +112,24 @@ class EventFormTest {
reminders: List<Reminder> = emptyList(),
availability: Availability = Availability.Busy,
accessLevel: AccessLevel = AccessLevel.Default,
rowStart: Long = 0L,
rowEnd: Long = 0L,
attendees: List<Attendee> = emptyList(),
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 7L,
title = title,
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(0L),
start = Instant.fromEpochMilliseconds(rowStart),
end = Instant.fromEpochMilliseconds(rowEnd),
isAllDay = isAllDay,
color = 0,
location = location,
),
description = description,
organizer = null,
attendees = emptyList(),
attendees = attendees,
rrule = rrule,
reminders = reminders,
availability = availability,
@@ -177,6 +180,41 @@ class EventFormTest {
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `snapshots of an unchanged event are equal`() {
val a = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val b = detail().toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(b).isEqualTo(a)
}
@Test
fun `an external field change makes snapshots differ`() {
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(title = "Stand-up (moved)").toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `an external time move is caught by the row times the form cannot see`() {
// Both snapshots are taken for the same tapped occurrence, so the
// *forms* derive identical times — only rowStart/rowEnd betray the move.
val loaded = detail(rrule = "FREQ=WEEKLY", rowStart = 0L)
.toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(rrule = "FREQ=WEEKLY", rowStart = 86_400_000L)
.toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh.form).isEqualTo(loaded.form)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `changes the form cannot write do not fake a conflict`() {
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
).toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isEqualTo(loaded)
}
@Test
fun `populatedFields reports exactly the sections holding values`() {
val empty = form().copy(location = "", description = "")