Compare commits
5 Commits
e710f930ca
...
4c0e85fdbf
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c0e85fdbf | |||
| 5b6bd36ece | |||
| 7ed19b37aa | |||
| 2c7d976d6f | |||
| 2685730c44 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user