Compare commits

...

5 Commits

Author SHA1 Message Date
4c0e85fdbf fix(imports): offer the manager when there is nothing to import into (#76)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 6m5s
Every writable calendar being switched off, read-only or filled from contacts is
exactly what empties the import picker — and that is the case the new "Missing a
calendar?" footer exists for. The empty state returns before the picker is laid
out, though, so it was the one screen that named no way out: the event form
offered the manager, the import dead-ended.

Carry the same route into the message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:49:40 +02:00
5b6bd36ece fix(calendars): hold the mirror delete on the marker, not the stored ids
The delete lock read the special-dates calendar ids out of preferences, which
the sync only rewrites on its next pass. Restore the app's data (or lose the
preferences any other way) while a mirror still exists and the feature is on,
and the lock is off until then: the delete goes through, looks like it worked,
and the next sync puts the calendar back.

The calendar itself carries the durable CAL_SYNC2 marker the editor lock already
trusts, so read the lock off that. It also covers the mirror of a type since
switched off, which the same sync pass deletes on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:49:40 +02:00
7ed19b37aa fix(pickers): keep non-syncing calendars out of the event targets (#78)
The new row states were documented as the reasons a calendar is missing from the
event and import pickers, but only three of the four were: a calendar whose
account keeps its events off the device is dimmed, switchless and marked "Not
synced" in the manager, and was still offered as a place to save a new or
imported event. Nothing there ever reaches the account, and CalendarProvider2
wipes the calendar's rows outright when the subscription is switched back on —
so the event is a dead end, saved into a calendar that also can't show it.

Both pickers now filter on one predicate, isEventTarget, which is the label set
restated: what the manager marks is what the picker leaves out, which is what
its "Missing a calendar?" footer promises. The manager's restore-from-backup row
uses it too, so the entry point and the picker behind it agree on whether there
is anywhere to import to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:49:29 +02:00
2c7d976d6f fix(calendars): don't reconcile visibility off an unreadable calendar list
The data source turns a null cursor — a provider that is momentarily
unavailable — into an empty list, which the reconciler read as "this device has
no calendars". Both of the decisions it then takes are one-way: every pending id
counts as settled, so the whole set is dropped without VISIBLE = 0 ever being
written and the calendars the user switched off come back on with their events
and reminders; and with nothing to find hidden, the one-time notice is stored as
"nothing to explain" for good.

The repository already refuses to cache an empty read for exactly this reason.
Do the same here and leave both decisions to the next run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:49:29 +02:00
2685730c44 fix(imports): let the manager step aside for the import it opens
The manager is declared last in the host's overlay Box so it covers everything
that can open it. But the restore-from-backup path runs the other way: the
manager asks the host for the import screen and stayed up, so the import drew
behind it — the file picker returned and nothing appeared to happen, and back
closed the manager onto a stale import screen.

The two can't both be on top, so the manager closes as it hands over. Coming
back from the import lands on whatever opened the manager in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:49:14 +02:00
11 changed files with 138 additions and 35 deletions

View File

@@ -14,8 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Calendars your device isn't syncing are marked **Not synced**, moved to the
bottom of their account and left without a switch. None of their events are on
the device, so the switch they used to have could not have shown you anything
— the calendar simply looked broken. Whether an account syncs a calendar stays
that account's own app's decision ([#78]).
— the calendar simply looked broken. They are no longer offered when you pick
a calendar for a new or an imported event either: an event saved there would
never reach the account. Whether an account syncs a calendar stays that
account's own app's decision ([#78]).
- The birthday and anniversary calendars Calendula fills from your contacts are
marked **Filled from your contacts**, which is why they can't be picked for a
new event: anything you put there would be removed again on the next sync.

View File

@@ -67,6 +67,14 @@ class CalendarVisibilityReconciler @Inject constructor(
// drain and nothing left to decide, so don't pay for the query.
if (pending.isEmpty() && noticeSettled) return@withContext
val calendars = dataSource.calendars()
// An empty read means "couldn't read", not "no calendars": the data
// source turns a null cursor — a provider momentarily unavailable —
// into an empty list. Both decisions below are one-way, so taking
// that reading as the truth would drop the whole pending set without
// ever writing VISIBLE = 0 (switching the user's calendars back on,
// events and reminders with them) and settle the notice as "nothing
// to explain". Leave both to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext

View File

@@ -41,6 +41,25 @@ val CalendarSource.isNotSynced: Boolean
val CalendarSource.hasVisibilitySwitch: Boolean
get() = !isNotSynced
/**
* Whether this calendar can be offered as a target for a new or imported event.
* The one predicate behind both pickers, so the states [CalendarStateLabel]
* names on a manager row are exactly the states that keep a calendar out of
* them (#76):
*
* - read-only has nowhere to write;
* - switched off would hide the event the moment it was saved;
* - a managed mirror has the next contact sync delete it;
* - a non-syncing one never carries the event up to the account, and
* `CalendarProvider2` wipes the calendar's rows outright when the
* subscription is switched back on — a saved event is a dead end either way.
*
* This is the test for *targets*. An event already living in an excluded
* calendar keeps it; the editor adds that calendar back to its picker.
*/
val CalendarSource.isEventTarget: Boolean
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
/** Every state worth naming on this calendar's row, in reading order. */
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
if (isManaged) add(CalendarStateLabel.MANAGED)

View File

@@ -489,7 +489,12 @@ fun CalendarHost(
) {
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
// The manager opens the import too (restore from backup), and
// that way round it has to step aside: declared above the import
// overlays, it would otherwise cover the screen it just asked
// for. Closing it hands the user back to whatever opened the
// manager once the import is done.
onImport = { importUri = it; importForceMany = true; showCalendars = false },
)
}
}

View File

@@ -94,6 +94,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.domain.stateLabels
@@ -335,9 +336,9 @@ private fun CalendarsList(
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any writable, non-managed calendar (local or
// synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
// Restore/import can target any calendar the import picker would offer
// (local or synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.isEventTarget }
if (exportable.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))

View File

@@ -75,18 +75,24 @@ class CalendarsViewModel @Inject constructor(
)
/**
* Managed special-dates calendars whose deletion would not stick. While its
* type is enabled, the sync recreates a missing mirror on the next pass
* (`SpecialDatesSyncEngine.reconcileCalendars`), so the delete would appear
* to work and then undo itself. Turning special dates off empties this set,
* and deleting a leftover mirror is a real delete from then on.
* Managed special-dates calendars whose deletion would not stick. While the
* feature is on, the sync owns every mirror: it recreates a missing one for
* an enabled type on the next pass and deletes the leftover of a disabled
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
* delete would appear to work and then undo itself. Turning special dates
* off empties this set, and deleting a leftover mirror is a real delete from
* then on.
*
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
* ids, which are only rewritten on the next sync pass — a preferences loss
* would otherwise unlock a live mirror until then.
*/
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
calendars,
settingsPrefs.specialDatesEnabled,
settingsPrefs.specialDatesTypes,
settingsPrefs.specialDatesCalendars,
) { enabled, types, byType ->
if (!enabled) emptySet() else types.mapNotNull { byType[it] }.toSet()
) { sources, enabled ->
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
}
.flowOn(io)
.stateIn(

View File

@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
@@ -177,19 +178,16 @@ class EventEditViewModel @Inject constructor(
repository.calendars().catch { emit(emptyList()) }
/**
* Writable calendars — the only valid event targets. Calendars switched off
* in Settings → Calendars are excluded, so you can't create into one you've
* turned off; a last-used preselect landing on a now-off calendar falls back
* to the first remaining writable one (handled by [resolvedCalendarId] and
* [state]). Managed special-dates calendars are excluded too: their events
* are owned by the contact sync, which would delete any user event created
* there.
* The calendars a new event can be saved to ([isEventTarget]): writable,
* switched on, not a contact-filled mirror, not a non-syncing subscription.
* A last-used preselect landing on an excluded calendar falls back to the
* first remaining one (handled by [resolvedCalendarId] and [state]).
*
* This is the list of *targets*. An event already living in an excluded
* calendar keeps it — [state] adds it back to the picker.
*/
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
calendars.filter { it.canModifyContents && it.isVisibleInSystem && !it.isManaged }
calendars.filter { it.isEventTarget }
}
/** The target calendar id, resolved exactly as the form shows it. */

View File

@@ -175,9 +175,18 @@ private fun ManyContent(
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
// No writable calendar to import into — tell the user honestly.
// No calendar to import into — tell the user honestly, and carry the same
// way out the picker's footer offers below. This is the state that footer
// exists for: every writable calendar being switched off, read-only or
// contact-filled is exactly what empties this list (#76).
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
CenteredMessage(
message = stringResource(R.string.import_no_calendar),
onClose = null,
actionLabel = stringResource(R.string.settings_manage_calendars)
.takeIf { onManageCalendars != null },
onAction = onManageCalendars,
)
return
}
@@ -345,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) {
}
@Composable
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
private fun CenteredMessage(
message: String,
onClose: (() -> Unit)?,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
Modifier.padding(24.dp),
@@ -356,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
if (onClose != null) {
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
}
if (actionLabel != null && onAction != null) {
Button(onClick = onAction) { Text(actionLabel) }
}
}
}
}

View File

@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -85,18 +86,14 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings,
)
else -> {
// A calendar switched off in Settings → Calendars is off
// everywhere, so it can't be an import target — exclude it
// alongside the read-only ones. Managed special-dates
// calendars are contact-derived and editor-locked, so
// they're not a valid destination either.
// The same targets the event form offers ([isEventTarget]):
// an import is a bulk create, so a calendar that can't hold
// one event can't hold thirty.
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter {
it.canModifyContents && !it.isManaged && it.isVisibleInSystem
},
.filter { it.isEventTarget },
)
}
}

View File

@@ -70,6 +70,30 @@ class CalendarRowStateTest {
assertThat(calendar.hasVisibilitySwitch).isTrue()
}
@Test
fun `every named state keeps a calendar out of the pickers`() {
// The labels and the picker exclusion are the same set, stated twice —
// a labelled row the pickers still offered would make the footer's
// "manage your calendars to see why" a lie (#76).
assertThat(cal().isEventTarget).isTrue()
listOf(
cal(writable = false),
cal(syncsEvents = false),
cal(local = true, managed = true),
).forEach { calendar ->
assertThat(calendar.stateLabels()).isNotEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
}
@Test
fun `a switched-off calendar is no target although it carries no label`() {
// The switch is right there on the row, so the state speaks for itself.
val calendar = cal().copy(isVisibleInSystem = false)
assertThat(calendar.stateLabels()).isEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
@Test
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
val ordered = listOf(

View File

@@ -45,9 +45,14 @@ class EventEditViewModelTest {
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun cal(id: Long, visible: Boolean = true): CalendarSource = CalendarSource(
private fun cal(
id: Long,
visible: Boolean = true,
syncsEvents: Boolean = true,
): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
syncsEvents = syncsEvents,
)
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
@@ -108,6 +113,27 @@ class EventEditViewModelTest {
job.cancel()
}
@Test
fun `a calendar whose account is not synced to this device is not a target`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Writable and switched on, but the account keeps its events off the
// device: nothing saved here ever reaches it, and the provider drops the
// rows when the subscription comes back (#76).
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
job.cancel()
}
@Test
fun `editing an event in a switched-off calendar keeps it in the picker`(
@TempDir tempDir: Path,