Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d028b70e6e | |||
| 626623bb6e |
@@ -53,7 +53,7 @@ after v0.6 (full event read) plus the onboarding-screen polish pass.
|
|||||||
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||||
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||||
|
|
||||||
## v2.0 — Write Support (in progress)
|
## v2.0 — Write Support (complete, shipped 2026-06-11)
|
||||||
|
|
||||||
Delivered in four releasable slices (plan:
|
Delivered in four releasable slices (plan:
|
||||||
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||||
@@ -66,7 +66,21 @@ guide here, not a contract — scope per slice is decided as we go.
|
|||||||
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||||
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||||
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
||||||
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
|
||||||
|
|
||||||
|
v2.0 scope was re-cut on 2026-06-11, after v1.4:
|
||||||
|
- **Occurrence edit** already shipped early, in v1.3.
|
||||||
|
- **Quick-add** is **cut from scope**: the full form already opens prefilled
|
||||||
|
(visible day, last-used calendar, optional fields hidden), so the sheet
|
||||||
|
would only save one screen transition while adding a second create-surface
|
||||||
|
to maintain. Revisit only if real-world feedback says creation feels heavy.
|
||||||
|
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
|
||||||
|
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
|
||||||
|
honest implementation is copy+delete like Google Calendar, with sync-identity
|
||||||
|
and attendee side effects).
|
||||||
|
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
|
||||||
|
the row as it was when the form loaded; on external change, ask
|
||||||
|
overwrite / discard. Closes the silent-clobber gap on synced calendars.
|
||||||
|
|
||||||
## v1.4 — Reminder Notifications
|
## v1.4 — Reminder Notifications
|
||||||
|
|
||||||
@@ -99,5 +113,7 @@ Deliberately deferred (add only if needed):
|
|||||||
- Full-text search
|
- Full-text search
|
||||||
- Tablet / foldable layouts
|
- Tablet / foldable layouts
|
||||||
- Optional: ICS file import (drag-and-drop)
|
- Optional: ICS file import (drag-and-drop)
|
||||||
|
- Optional: move event to another calendar (copy+delete model with a
|
||||||
|
consequences warning — deferred from v2.0, see above)
|
||||||
|
|
||||||
Order is indicative — community feedback after V1 may re-prioritize.
|
Order is indicative — community feedback after V1 may re-prioritize.
|
||||||
|
|||||||
19
CHANGELOG.md
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.0.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Conflict handling when saving an edit: if the event changed elsewhere
|
||||||
|
(sync, another device) while the form was open, saving now asks whether
|
||||||
|
to keep or discard your changes instead of silently overwriting the
|
||||||
|
edited fields — and tells you when the event was deleted in the meantime.
|
||||||
|
"Keep" still writes only the fields you touched; external changes to
|
||||||
|
untouched fields survive either way
|
||||||
|
- F-Droid store screenshots (German + English), captured with demo data
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- F-Droid description and README no longer claim the app is read-only —
|
||||||
|
they now describe write support and reminder delivery
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `versionName`/`versionCode` bumped to 2.0.0 / 13 — closing out the
|
||||||
|
write-support milestone (v1.1 through v2.0)
|
||||||
|
|
||||||
## [1.4.0] — 2026-06-11
|
## [1.4.0] — 2026-06-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ word "calendar". Calendula reads from Android's built-in `CalendarContract`,
|
|||||||
so any calendar source synced to your device (CalDAV via DAVx5, Google,
|
so any calendar source synced to your device (CalDAV via DAVx5, Google,
|
||||||
local, WebCal subscriptions, ...) is shown.
|
local, WebCal subscriptions, ...) is shown.
|
||||||
|
|
||||||
## Features (V1)
|
## Features
|
||||||
|
|
||||||
- Month, Week, and Day views
|
- Month, Week, and Day views
|
||||||
- Read-only event details (write support comes in V2)
|
- Full event details — attendees, reminders, recurrence, availability, and more
|
||||||
|
- Create, edit, and delete events — recurring events with scoped writes
|
||||||
|
(only this event / this and all following / whole series) and a simple
|
||||||
|
recurrence picker
|
||||||
|
- Reminder notifications, delivered by Calendula itself (tap opens the event)
|
||||||
- Multi-calendar visibility toggle
|
- Multi-calendar visibility toggle
|
||||||
- Material You Dynamic Color (Android 12+)
|
- Material You Dynamic Color (Android 12+)
|
||||||
- Light/Dark theme follows system
|
- Light/Dark theme follows system
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 12
|
versionCode = 13
|
||||||
versionName = "1.4.0"
|
versionName = "2.0.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* 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
|
* must be visible regardless of the user's default-fields setting, or the
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ fun EventEditScreen(
|
|||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
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,
|
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.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
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
|
* 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
|
data object Idle : SaveUiState
|
||||||
/** A dirty recurring event waits for the user to pick the write scope. */
|
/** A dirty recurring event waits for the user to pick the write scope. */
|
||||||
data object AwaitingScope : SaveUiState
|
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 Saving : SaveUiState
|
||||||
data object Saved : SaveUiState
|
data object Saved : SaveUiState
|
||||||
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
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.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
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.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -68,15 +70,21 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
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
|
* 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(
|
private data class EditTarget(
|
||||||
val eventId: Long,
|
val eventId: Long,
|
||||||
val original: EventForm,
|
val snapshot: EditSnapshot,
|
||||||
val beginMillis: Long,
|
val beginMillis: Long,
|
||||||
)
|
val endMillis: Long,
|
||||||
|
val zone: TimeZone,
|
||||||
|
) {
|
||||||
|
val original: EventForm get() = snapshot.form
|
||||||
|
}
|
||||||
|
|
||||||
private data class LocalInputs(
|
private data class LocalInputs(
|
||||||
val form: EventForm?,
|
val form: EventForm?,
|
||||||
@@ -167,11 +175,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_loadFailed.value = true
|
_loadFailed.value = true
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault())
|
val zone = TimeZone.currentSystemDefault()
|
||||||
_editTarget.value = EditTarget(eventId, original, beginMillis)
|
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.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = original.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = original
|
_form.value = snapshot.form
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,10 +258,43 @@ class EventEditViewModel @Inject constructor(
|
|||||||
performSave(current.form, scope)
|
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
|
val target = _editTarget.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_saveState.value = SaveUiState.Saving
|
_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 {
|
_saveState.value = try {
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
repository.createEvent(form)
|
repository.createEvent(form)
|
||||||
|
|||||||
@@ -82,6 +82,16 @@
|
|||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</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) -->
|
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||||
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</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) -->
|
<!-- Event form — recurrence picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||||
<string name="event_edit_recurrence_custom">Custom</string>
|
<string name="event_edit_recurrence_custom">Custom</string>
|
||||||
|
|||||||
@@ -112,21 +112,24 @@ class EventFormTest {
|
|||||||
reminders: List<Reminder> = emptyList(),
|
reminders: List<Reminder> = emptyList(),
|
||||||
availability: Availability = Availability.Busy,
|
availability: Availability = Availability.Busy,
|
||||||
accessLevel: AccessLevel = AccessLevel.Default,
|
accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
rowStart: Long = 0L,
|
||||||
|
rowEnd: Long = 0L,
|
||||||
|
attendees: List<Attendee> = emptyList(),
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
eventId = 1L,
|
eventId = 1L,
|
||||||
calendarId = 7L,
|
calendarId = 7L,
|
||||||
title = title,
|
title = title,
|
||||||
start = Instant.fromEpochMilliseconds(0L),
|
start = Instant.fromEpochMilliseconds(rowStart),
|
||||||
end = Instant.fromEpochMilliseconds(0L),
|
end = Instant.fromEpochMilliseconds(rowEnd),
|
||||||
isAllDay = isAllDay,
|
isAllDay = isAllDay,
|
||||||
color = 0,
|
color = 0,
|
||||||
location = location,
|
location = location,
|
||||||
),
|
),
|
||||||
description = description,
|
description = description,
|
||||||
organizer = null,
|
organizer = null,
|
||||||
attendees = emptyList(),
|
attendees = attendees,
|
||||||
rrule = rrule,
|
rrule = rrule,
|
||||||
reminders = reminders,
|
reminders = reminders,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
@@ -177,6 +180,41 @@ class EventFormTest {
|
|||||||
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
|
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
|
@Test
|
||||||
fun `populatedFields reports exactly the sections holding values`() {
|
fun `populatedFields reports exactly the sections holding values`() {
|
||||||
val empty = form().copy(location = "", description = "")
|
val empty = form().copy(location = "", description = "")
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ Domain bleibt pure Kotlin.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
||||||
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
||||||
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | implementiert (Release wartet auf On-Device-Review) |
|
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | ausgeliefert (v1.3.0, 2026-06-11) |
|
||||||
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
|
| v2.0 | Konflikt-Dialog, Polish-Pass (Store-Copy, Screenshots), Release | ausgeliefert (v2.0.0, 2026-06-11) |
|
||||||
|
|
||||||
## v1.1 — Write-Fundament + Delete
|
## v1.1 — Write-Fundament + Delete
|
||||||
|
|
||||||
@@ -180,9 +180,25 @@ Domain bleibt pure Kotlin.
|
|||||||
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
|
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
|
||||||
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
|
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
|
||||||
|
|
||||||
## v2.0 — Abschluss (Skizze)
|
## v2.0 — Abschluss (Scope-Recut 2026-06-11, nach v1.4)
|
||||||
|
|
||||||
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
|
- ~~Quick-Add-Sheet (Titel + Zeit, Rest Defaults)~~ — **gestrichen**: das
|
||||||
- Occurrence-Edit (Exception mit geänderten Werten)
|
Formular öffnet bereits vorbefüllt (sichtbarer Tag, zuletzt benutzter
|
||||||
- Konflikt-Dialog beim Speichern
|
Kalender, optionale Felder versteckt); der Sheet spart nur einen
|
||||||
- Changelog, F-Droid-Metadaten, Release-Tag
|
Screen-Übergang und kostet eine zweite Create-Surface. Nur bei
|
||||||
|
Praxis-Feedback wieder aufnehmen
|
||||||
|
- ~~Occurrence-Edit (Exception mit geänderten Werten)~~ — schon in v1.3
|
||||||
|
ausgeliefert (vorgezogen)
|
||||||
|
- [x] Konflikt-Dialog beim Speichern (Leitentscheidung 5): `EditSnapshot`
|
||||||
|
(Formular + rohe Row-Zeiten) wird beim Laden gemerkt und vor dem
|
||||||
|
Schreiben gegen einen frischen Read verglichen; Abweichung parkt den
|
||||||
|
Save in `AwaitingConflict` (Überschreiben/Verwerfen/Abbrechen,
|
||||||
|
OptionCard-Stil), gelöschtes Event → `Gone`-Dialog. "Überschreiben"
|
||||||
|
schreibt weiterhin nur dirty Felder
|
||||||
|
- Kalender-Wechsel beim Bearbeiten → v3-Backlog (copy+delete-Modell)
|
||||||
|
- [x] Polish: F-Droid-Description + README auf Write-Support + Reminder
|
||||||
|
aktualisiert (DE+EN)
|
||||||
|
- [x] F-Droid-Screenshots (de-DE + en-US, je 6: Woche/Monat/Tag/Detail/
|
||||||
|
Formular/Onboarding) — mit Demo-Kalendern auf dem Gerät aufgenommen
|
||||||
|
- [x] Changelog, Release-Tag v2.0.0 (ausgeliefert 2026-06-11 — Milestone 2
|
||||||
|
damit abgeschlossen)
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie liest
|
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie
|
||||||
direkt aus dem System-Kalender-Provider — jede Quelle, die mit deinem Gerät
|
arbeitet direkt auf dem System-Kalender-Provider — jede Quelle, die mit
|
||||||
synchronisiert ist (Nextcloud über DAVx5, Google, lokal, WebCal-Subscriptions)
|
deinem Gerät synchronisiert ist (Nextcloud über DAVx5, Google, lokal,
|
||||||
erscheint automatisch.
|
WebCal-Subscriptions), erscheint automatisch, und deine Änderungen
|
||||||
|
synchronisieren auf demselben Weg zurück.
|
||||||
|
|
||||||
|
Termine erstellen, bearbeiten und löschen — auch wiederkehrende, mit
|
||||||
|
wählbarer Reichweite (nur dieser Termin / dieser und alle folgenden / ganze
|
||||||
|
Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
|
||||||
|
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
||||||
|
Termin.
|
||||||
|
|
||||||
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
||||||
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
||||||
|
|
||||||
V1 ist read-only. Erstellen, Bearbeiten und Löschen von Events kommt mit V2.
|
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff —
|
||||||
|
deine Daten bleiben auf dem Gerät.
|
||||||
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff — deine
|
|
||||||
Daten bleiben auf dem Gerät.
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 156 KiB |
@@ -1,11 +1,15 @@
|
|||||||
Calendula is a modern, open-source calendar app for Android. It reads from
|
Calendula is a modern, open-source calendar app for Android. It works
|
||||||
the system calendar provider, so any source synced to your device — Nextcloud
|
directly on the system calendar provider, so any source synced to your
|
||||||
via DAVx5, Google, local, WebCal subscriptions — shows up automatically.
|
device — Nextcloud via DAVx5, Google, local, WebCal subscriptions — shows up
|
||||||
|
automatically, and changes you make sync back the same way.
|
||||||
|
|
||||||
The differentiator is the design: real Material 3 Expressive throughout, with
|
Create, edit and delete events, including recurring events with scoped
|
||||||
dynamic color, expressive motion, and expressive shapes.
|
changes (only this event / this and all following / the whole series) and a
|
||||||
|
simple repeat picker. Calendula also delivers your event reminders as
|
||||||
|
notifications — tap one and you're on the event.
|
||||||
|
|
||||||
V1 is read-only. Event creation, editing, and deletion are planned for V2.
|
The differentiator is the design: real Material 3 Expressive throughout,
|
||||||
|
with dynamic color, expressive motion, and expressive shapes.
|
||||||
|
|
||||||
Privacy: zero telemetry, no analytics, no network access — your data never
|
Privacy: zero telemetry, no analytics, no network access — your data never
|
||||||
leaves the device.
|
leaves the device.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 135 KiB |