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:
@@ -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 = "")
|
||||
|
||||
Reference in New Issue
Block a user