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:
@@ -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
|
||||
|
||||
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user