Compare commits

...

43 Commits

Author SHA1 Message Date
65bd6c4254 feat(intent): create events from external ACTION_INSERT launches (#30)
Register an intent-filter for ACTION_INSERT on the events dir mime type
(vnd.android.cursor.dir/event), the way the AOSP calendar accepts one, so
other apps and widgets (e.g. the Todo Agenda widget) can launch Calendula
to create a new event.

MainActivity.insertFormOrNull parses the standard CalendarContract extras
(EXTRA_EVENT_BEGIN_TIME/END_TIME/ALL_DAY, Events.TITLE/DESCRIPTION/
EVENT_LOCATION/RRULE) into a prefilled EventForm via the pure, unit-tested
buildInsertEventForm — omitted fields fall back to the same defaults the
in-app "new event" uses (next full hour, +1h). The form is routed through
the existing single-event prefill channel (RootScreen → CalendarHost →
the create form for review), with calendarId left null so it resolves to
the last-used / first-writable calendar. No new permission is needed
(WRITE_CALENDAR is already held), and the user still explicitly saves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:12:56 +02:00
2239c38ecc Merge pull request 'release: v2.13.0 — special dates, custom fonts, quick-switch, es/it translations' (!58) from release/v2.13.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Successful in 11m16s
Renovate / renovate (push) Successful in 1m12s
Reviewed-on: #58
2026-07-03 14:47:42 +00:00
79ad70e0c0 release: cut v2.13.0 changelog and fastlane notes
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 9m3s
Move the accumulated [Unreleased] section under [2.13.0] — 2026-07-03,
adding the entries that were still missing: Spanish + Italian community
translations (thanks kikerw and corrent via Weblate), custom fonts (#19),
configurable quick-switch cycle + drawer order (#24), and the scrollable
event-editor calendar picker (#29). Regenerate fastlane/.../changelogs/
21300.txt via scripts/sync_changelog_to_fastlane.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:25:39 +02:00
f168021f51 release: bump version to 2.13.0 (21300)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:25:39 +02:00
a2b1e7792b chore(i18n): drop stale reminder_custom_with_value from es/it
The source key was removed on this branch; the Weblate translations
merged from main still carried it, tripping check_translations.py and
fatal ExtraTranslation lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:10:31 +02:00
c6ce0830b1 Merge remote-tracking branch 'origin/main' into release/v2.13.0 2026-07-03 16:09:51 +02:00
be13f97802 feat(i18n): surface Spanish and Italian in the language pickers
Community translations for es and it reached ~85% coverage (Weblate
PR #52 into main). Adding their locale entries makes them selectable in
the in-app picker and Android 13+ per-app language settings once that
PR is merged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:05:22 +02:00
f6d40de832 chore(i18n): strip hand-edited German strings — German returns via Weblate
Weblate owns all translation files including values-de; devs only touch
the English base. Reverts the hand-added special-dates strings and the
permission_rationale_body rewording to main's version (texts recoverable
from 1ff4115, to be re-added through the Weblate API). Keeps the removal
of the stale reminder_custom_with_value key, whose source string was
deleted on this branch (ExtraTranslation is fatal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:05:22 +02:00
fdee311658 Merge pull request 'Translations update from Weblate' (!52) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #52
2026-07-03 14:04:16 +00:00
1ff41153e9 Merge pull request 'feat(contacts): mirror contact special dates into local calendars (#15)' (!57) from feat/contact-special-dates into release/v2.13.0
Reviewed-on: #57
2026-07-03 13:44:46 +00:00
49d04bcee0 Merge remote-tracking branch 'origin/release/v2.13.0' into feat/contact-special-dates
All checks were successful
Translations / check (pull_request) Successful in 26s
CI / ci (pull_request) Successful in 5m19s
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
#	app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
2026-07-03 15:41:38 +02:00
5e470baf4e Merge pull request 'fix(edit): scrollable, grouped, pinned-header calendar picker (#29)' (!56) from fix/calendar-picker-scrollable into release/v2.13.0
Reviewed-on: #56
2026-07-03 13:35:48 +00:00
84e6402031 fix(contacts): serialize sync/teardown and stop retrying on lost permission
- The periodic job and the immediate 'Sync now'/foreground runs had no mutual
  exclusion, so two overlapping reconciles could each see no managed calendar
  and both create one (duplicate 'Birthdays', doubled events); likewise a
  disable-teardown racing an in-flight sync got its calendars recreated right
  after deletion. A lifecycle Mutex now makes sync() and teardown() atomic, and
  sync() re-reads the enabled flag inside the lock so a teardown always wins.

- The foreground resume trigger shared a unique work name with enable/'Sync
  now' under ExistingWorkPolicy.REPLACE, so a debounced foreground enqueue could
  cancel-and-swallow a pending enable sync (feature on, no calendars for up to a
  day). It now uses its own work name.

- doWork() mapped every exception to retry(), so a revoked calendar permission
  retried with backoff forever and never surfaced. A SecurityException now parks
  the feature in the stalled state instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:04:26 +02:00
7fe59b36c3 fix(contacts): keep distinct custom dates instead of collapsing them
The mirror keyed each event on (contact, type) only, and the read deduped on
that same pair from an unordered query. So a contact with two custom dates
('Wedding', 'Graduation') mirrored just one — and, the winner being
order-dependent, the single event could ping-pong between the two dates across
syncs. A Custom date's UID now carries a discriminator (its label, else its
month-day), the read dedups on the full UID, and the query is ordered by
Data._ID so a genuine duplicate resolves the same way every time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:04:06 +02:00
8881559e67 fix(contacts): scope managed-event queries + fix reminder fire hour
Two provider-level bugs in the managed-event data path:

- queryManagedEvents and applyManagedCalendarReminders matched every event in
  the calendar (UID_2445 IS NOT NULL / no filter). A stray user event there
  (e.g. an .ics import) was treated as 'existing but not desired' and deleted,
  or had its own reminders wiped and all-day-re-encoded. Both now match only
  our own mirror events (the 'contact-' UID prefix).

- All-day reminder offsets were sampled at the event's DTSTART, which for a
  year-less birthday is the 1972 leap anchor — a year whose timezone offset
  (pre-DST) differs from today's, skewing every modern occurrence by up to an
  hour. The offset is now sampled at the upcoming occurrence (nextYearlyOccurrence),
  leaving only the inherent ±1h DST drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:03:51 +02:00
9f7e93d5f8 fix(contacts): seal managed calendars off from user edits
Managed special-dates calendars were only recognised by an id cached in
preferences, and were offered as ordinary write targets — so a user could
create an event in 'Birthdays' (which the next sync then deleted), and after
a backup restore wiped the prefs the editor lock silently disappeared.

CalendarSource now carries isManaged, read from the durable CAL_SYNC2 marker
the data layer already stamps, so identity survives a restore. With it:
- the new-event calendar picker excludes managed calendars (no user events
  land in a calendar the sync owns);
- the editor lock keys off the marker, not a stored id;
- a managed event's save is forced to the whole series instead of parking in
  the scope dialog (a 'this/following' split creates rows the sync reverts);
- the detail sheet hides Delete for managed events (the sync would just
  resurrect them — a contact date is removed at its source).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:03:33 +02:00
86d4e11584 refactor(contacts): simplify the sync engine's gates and context
- The worker no longer pre-checks specialDatesEnabled: the engine already
  gates on it, and the worker now consumes the previously-unreachable
  Disabled result instead (without recording a run).
- ReminderContext drops the timed defaults it could never use — managed
  events are always all-day — saving two DataStore reads per sync.
- createCalendar no longer persists the calendar id itself;
  reconcileCalendars is the single place ids are recorded (it always
  followed up with the same write).
- The title-template fallback uses the shared resolveTitleTemplate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:44 +02:00
67a07a9346 refactor(settings): dedupe override decoding + on-convention template input
- choiceFor moves next to CalendarReminderOverride in SettingsPrefs and
  replaces the hand-rolled null/empty/else decoder in SettingsViewModel,
  so the picker and the special-dates rows can't drift apart.
- The special-dates template dialog used the codebase's only
  OutlinedTextField; it now follows the documented dialog convention
  (InlineTextField over a tonal surface, see DialogControls).
- reminderChoiceLabel drops the manual loop (and its wrong comment —
  map is inline, so composable calls are fine in its lambda).
- The blank-template fallback is shared with the sync engine via
  resolveTitleTemplate instead of being copy-pasted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:44 +02:00
b8a420bdab refactor(contacts): one Context.hasContactsPermission() helper
The READ_CONTACTS check was written out three times (data source, RootScreen
resume trigger, settings screen). A single extension in the contacts package
mirrors the existing Context.hasCalendarPermission() precedent; the settings
call site follows in the settings cleanup commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:44 +02:00
442697d57c refactor(calendar): move the colour palette to data with named entries
AndroidSpecialDatesCalendarSpec hardcoded three ARGB literals that its KDoc
claimed were 'picked from the shared palette' — a silent-drift hazard, and
referencing ui.common from data would invert layering. The palette now lives
in data/calendar as CalendarColorPalette with named hues; the swatch pickers
use CalendarColorPalette.all and the spec references Purple/Red/Blue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:26 +02:00
8e3a17fdc3 refactor(calendar): share the insert values builder + reminder seeding
insertManagedEvent duplicated ~45 lines of insertEvent (ContentValues build
and the reminder-seeding loop). Both now go through buildEventInsertValues
in EventWriteMapper and a shared seedReminders, differing only in the UID
they stamp and the colour/attendee columns insertEvent layers on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:40:26 +02:00
Weblate
5844539db2 Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 3m58s
Currently translated at 100.0% (363 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-02 13:20:25 +00:00
Sam Gamgee
2cc83d7714 Translated using Weblate (Italian)
Currently translated at 100.0% (363 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-02 13:20:25 +00:00
iker Contreras
dada1bbe72 Translated using Weblate (Spanish)
Currently translated at 100.0% (363 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-02 13:20:22 +00:00
Weblate
e061cbe127 Translated using Weblate (Spanish)
Currently translated at 100.0% (363 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-02 13:20:19 +00:00
4e125e58d5 feat(contacts): show source year instead of age in titles
A FREQ=YEARLY event has one static title, so a per-occurrence age is impossible
without heavy per-year exception rows. Replace {age} with {year} — the birth
year (or an anniversary's start year) — which is static and correct on every
occurrence, and shows everywhere (widgets, other apps, exports).

- renderSpecialDateTitle substitutes {year}; drop the age snapshot computation
  and the sync `today` parameter.
- Default templates become "{name}'s birthday ({year})" / "…anniversary ({year})".
- Rename the setting to "Show year" (prefs specialDatesShowYear) and remove the
  now-unneeded {age} snapshot disclaimer in the template editor.
- Update tests, CHANGELOG and the design-doc note.

lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:19:04 +02:00
52816b327a feat(contacts): per-type title format in the special-dates section
The "Title format" row was only editable for Birthdays; move it into each
per-type card so Anniversaries and Other dates get their own editable template
too (the dialog was already keyed by type). Each enabled type card now reads
toggle → title format → reminders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:54:11 +02:00
cbb241bc4d feat(contacts): calendar-wide reminders for special-dates calendars
Managed calendars now treat reminders as a calendar-level setting instead of the
seed-once-per-event model, matching how these homogeneous birthday/anniversary
calendars are actually used.

- New per-type "Reminders" control in the Contact special dates section. Changing
  it persists the per-calendar all-day override (so new events match) AND
  re-applies the set to *all existing events* in that calendar
  (CalendarDataSource.applyManagedCalendarReminders → SpecialDatesSyncEngine
  .applyReminders), encoding each event's all-day offset from its own date.
- Settings → Notifications: the contact-date calendars no longer offer a
  per-calendar override row; they show a link that jumps to the Contact special
  dates section (managedCalendarIds now in SettingsUiState).

Tests cover applyReminders (override persisted + bulk-apply invoked, and None
clears). lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:41:52 +02:00
0fd579b19b feat(contacts): drop {age} from default titles, disclaim it when added
A FREQ=YEARLY event has one static title, so {age} is only a sync-time snapshot
and can look wrong on far-future occurrences. Keep it out of the default
templates ({name}'s birthday / anniversary), and when a user does add {age} in
the title-format editor, show a short disclaimer about the snapshot behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:51:42 +02:00
030c3e6ce1 docs(design): mark contact special-dates implemented + note deviations
Records the UID_2445 (vs SYNC_DATA1) identity decision, the sync-time age
snapshot, and the seeded reminder default; checks off the #14/#15 task lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:17:23 +02:00
e67960d352 docs(contacts): translations, store copy + changelog for special dates
- German translations for the special-dates sub-page, the managed-field editor
  hint, and the calendar/title strings.
- Store listing (en/de): a paragraph on the optional, on-device, one-way contact
  special-dates feature.
- Soften the calendar-permission rationale ("all it asks for up front") now that
  an optional contacts permission exists, without weakening the privacy promise.
- CHANGELOG entry for #15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:16:11 +02:00
22bbfef3e2 feat(contacts): settings sub-page for contact special dates
A dedicated sub-page under Settings drives the mirror:

- master enable toggle — requests READ_CONTACTS contextually (only here, never at
  startup), runs an immediate sync on enable, and on disable confirms then tears
  the managed calendars down;
- per-type toggles (Birthdays / Anniversaries / Other), each confirm-guarded
  since turning one off deletes its calendar;
- an editable title template ({name}/{age}) and a Show-age toggle;
- a "Sync now" row with the last-run time, a paused/permission banner with a
  re-grant button, and a pointer to per-calendar colour/visibility/reminders.

Exposed via a separate SpecialDatesUiState flow (the main settings combine is
already at capacity); the ViewModel gained the engine/scheduler wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:12:08 +02:00
4da9abe05b feat(contacts): lock managed fields in the event editor
When editing an event in a special-dates calendar, the title, all-day/date and
recurrence are locked (the sync overwrites them) while reminders, location, notes
and colour stay editable — the field-level "managed" contract from the design.

- EventEditUiState.isManaged (calendar id ∈ managedCalendarIds while editing),
  wired through the ViewModel's state combine.
- InlineTextField/InlineField gain an `enabled` flag (dims + goes read-only);
  ScheduleRow dims and disables its date/time taps; the all-day switch and the
  recurrence card are disabled.
- A one-line "Managed by …" hint under the title explains what's editable.

The calendar picker needs no change — it's already create-only, and managed
events are always edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:06:00 +02:00
4540faf2a0 feat(contacts): schedule + lifecycle for the special-dates mirror
Mirrors the auto-backup infrastructure. A daily periodic WorkManager job plus an
immediate run on enable/"Sync now" and a debounced foreground run keep the mirror
fresh without a ContentObserver.

- SpecialDatesScheduler.apply/runNow + SpecialDatesSyncWorker (EntryPoint):
  respects the toggle, debounces foreground runs (4h), and parks the feature in a
  PermissionRevoked stalled state (recorded for the settings banner) instead of
  retrying forever when READ_CONTACTS is gone.
- CalendulaApp.onCreate reconciles the schedule on every launch.
- RootScreen ON_RESUME enqueues a debounced foreground sync, gated on the
  READ_CONTACTS grant so opted-out users never enqueue it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:01:29 +02:00
3aecb19a0c feat(contacts): idempotent special-dates sync engine
The engine reconciles device contact dates into the per-type local calendars.
Each run is an idempotent diff keyed on the deterministic UID_2445:

- new contacts inserted (seeding reminders via resolveDefaultReminder, plus a
  useful per-calendar all-day default of on-the-day + a week before so birthdays
  get lead time out of the box);
- changed contacts get a targeted managed-column update (title/dtstart/rrule
  only) — reminders/location/notes are never re-touched, so user edits survive;
- removed contacts deleted.

Managed calendars are created/adopted/removed per enabled type (reconcileCalendars,
self-healing against a stored-id that no longer exists), all-day FREQ=YEARLY
events anchored at the birth year (or a leap anchor when year-less). Pure helpers
(uid, anchor, age snapshot, title templating) and the diff are extracted for
unit testing; a stateful fake exercises full-run idempotency and user-data
preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:58:26 +02:00
f7c0b8a957 feat(contacts): special-dates preferences + managed-calendar identity
Adds the SettingsPrefs surface the mirror is configured and reconciled through:
master enable, per-type toggles (default all three on), per-type managed calendar
id (source of truth for the editor's managedCalendarIds and the sync targets),
editable per-type title template, show-age, and a run/stalled status plus a
foreground-sync debounce timestamp.

Calendar ids are stored per type under their own long key (no serialization/
escaping); templates likewise per type. SpecialDatesStatus/StalledReason model
the paused (permission-revoked) state for the settings banner.

Extends the test FakeCalendarDataSource with a stateful managed-event surface
(inserts reflected in later queries) so the sync engine can be exercised for
idempotency. Round-trip tests for every new pref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:27:36 +02:00
9a8d9b2177 feat(contacts): add managed-event write surface to CalendarDataSource
The special-dates mirror needs a durable, user-invisible per-event key so a
re-sync diffs instead of duplicating. Rather than Events.SYNC_DATA1 (which the
provider drops on a LOCAL calendar unless written via a sync-adapter URI), the
key is a deterministic UID_2445 (contact-<type>:<lookupKey>@calendula), written
and read through the normal event URIs the app already uses.

- createManagedCalendar: a normal local calendar plus a CAL_SYNC2 marker, so the
  mirror can re-adopt its calendars after a prefs wipe (findManagedCalendars).
- queryManagedEvents: reads back _ID/UID_2445/TITLE/DTSTART/RRULE (DELETED=0) as
  the existing side of the diff.
- insertManagedEvent: writes the deterministic UID and seeds reminder rows once.
- updateManagedFields: a bare, targeted column update — never reconciles
  reminders/attendees, so user-owned event data survives every sync.

Reuses toWriteTimes/toRfc2445Duration (all-day UTC-midnight DTSTART + P1D
DURATION for the FREQ=YEARLY row) and the existing deleteEvent for removals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:22:28 +02:00
93ca38d974 feat(contacts): declare READ_CONTACTS + read contact special-dates
Codeberg #15, foundation for the contact special-dates calendars. Declares the
optional, feature-gated READ_CONTACTS permission (never requested at startup)
and adds the offline, read-only contacts reader.

- AndroidManifest: READ_CONTACTS with a comment documenting the opt-in/offline
  one-way-mirror contract.
- domain/contacts: SpecialDateType + ContactSpecialDate model and a pure
  parseContactEventDate covering full (yyyy-MM-dd), year-less (--MM-dd) and
  compact (yyyyMMdd) shapes, with Feb-29 handling via a leap anchor.
- data/contacts: ContactSpecialDatesDataSource querying ContactsContract.Data
  Event rows, split by TYPE, deduped per (contact, type); returns empty without
  the permission so sync can degrade to a stalled state. Hilt-bound.
- Unit tests for the date parser (full/year-less/compact/Feb-29/malformed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:19:42 +02:00
a38093cae3 Merge branch 'feat/per-calendar-multi-reminders' into feat/contact-special-dates 2026-07-01 10:14:29 +02:00
Weblate
67511ce9be Update translation files
All checks were successful
Translations / check (pull_request) Successful in 26s
CI / ci (pull_request) Successful in 9m49s
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-01 07:25:20 +00:00
Sam Gamgee
2e90682874 Translated using Weblate (Italian)
Currently translated at 44.3% (161 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-01 07:25:20 +00:00
iker Contreras
2a4cb6a896 Translated using Weblate (Spanish)
Currently translated at 34.7% (126 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-01 07:25:17 +00:00
25ff7ce7ca docs(design): contact special-dates calendars + per-calendar multiple reminders
Capture the full design for surfacing contact birthdays/anniversaries/custom
dates as auto-updating local calendars (Codeberg #15), plus the #14
prerequisite (per-calendar multiple default reminders). One local calendar per
type to reuse existing per-calendar color/visibility/reminder infra; one-way
mirror with field-level managed events keyed on contact LOOKUP_KEY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:58:14 +02:00
51 changed files with 3929 additions and 107 deletions

View File

@@ -5,9 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.13.0] — 2026-07-03
### Added
- Calendula now speaks Spanish and Italian. Both arrived as community
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
— a **huge thank you** to
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
translation and to
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
one! Pick your language under Settings → Language or in Android's per-app
language settings. Strings added in this very release may still show in
English until the translations catch up. Want Calendula in your language?
Translating happens entirely in the browser — every contribution is welcome.
- See your contacts' birthdays and anniversaries in your calendar. A new,
**optional** feature (Settings → Contact special dates) mirrors your contacts'
birthdays, anniversaries and other dates into local "Birthdays",
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
change. Each is a normal local calendar, so you set its colour, visibility and
reminders the usual way — new birthdays even start with a reminder a week before
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
This is the first feature to use the contacts permission: it is requested only
when you turn the feature on, everything stays on your device (Calendula has no
internet access), and your contacts are only ever read, never changed. Thanks
to @moonj for the suggestion ([#15]).
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
@@ -22,6 +43,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
date number — now opens that day, and the "today" button snaps the grid back
to the current month in place. Thanks to @rgz46vic and @ptab for the
suggestions ([#18], [#20]).
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
and **Body font** pickers: keep the system default, choose a bundled face
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
face), or load any `.ttf`/`.otf` file from your device, independently per
role. Font size and colour stay with Android's accessibility scaling and the
app theme, as discussed on the issue. Thanks to @abrossimow for the
suggestion ([#19]).
- Choose what the view-switch button cycles through. Settings → Appearance now
lets you pick which views the top-right quick-switch button rotates between
and drag them into your preferred order; views you switch off stay reachable
from the navigation menu, which can be reordered the same way. Thanks to
@abrossimow for the suggestion ([#24]).
### Fixed
- Month widget arrows and "today" button work again. On release builds the
@@ -38,6 +71,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
- Every calendar can be picked when creating an event. The event editor's
calendar picker was a fixed-height dialog, so with many calendars anything
past the first nine or so was simply unreachable. It is now a full-screen,
scrollable list grouped by account, with each calendar's colour shown.
Thanks to @dschuermann for the report ([#29]).
## [2.12.0] — 2026-06-28
@@ -764,7 +802,11 @@ automatically, with zero telemetry and no internet permission.
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21200
versionName = "2.12.0"
versionCode = 21300
versionName = "2.13.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -5,6 +5,15 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Optional and feature-gated: only the "Contact special dates" feature reads
contacts, and only after the user enables it and grants this at runtime
(never requested at startup). Everything stays offline — birthdays and
other contact dates are mirrored one-way into local calendars; contacts
are never written and nothing leaves the device (the app has no INTERNET
permission). See docs/design/contact-special-dates.md.
-->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!--
Lets the "Reliable delivery" setting open the direct system dialog to
exempt Calendula from battery optimisation (so reminder broadcasts aren't
@@ -103,6 +112,17 @@
<data android:mimeType="text/calendar" />
</intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events dir mime type, carrying the new
event's fields as CalendarContract extras
(MainActivity.insertFormOrNull, issue #30). -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data
android:name="android.app.shortcuts"

View File

@@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -25,6 +27,7 @@ class CalendulaApp : Application() {
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
reconcileAutoBackup()
reconcileSpecialDates()
}
/**
@@ -44,4 +47,21 @@ class CalendulaApp : Application() {
)
}
}
/**
* Re-arm (or cancel) the daily special-dates reconcile from the saved
* settings on every launch — like [reconcileAutoBackup], this cancels
* orphaned work after the feature is turned off and re-schedules after a
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
* refresh, so no immediate run is needed here.
*/
private fun reconcileSpecialDates() {
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
SpecialDatesScheduler.apply(
context = this@CalendulaApp,
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
)
}
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.CalendarContract
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
@@ -25,6 +26,8 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -42,6 +45,7 @@ import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
@AndroidEntryPoint
@@ -60,6 +64,11 @@ class MainActivity : AppCompatActivity() {
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30). Consumed once by
// CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
@@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() {
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null },
)
}
// A persistent corner marker so a debug build is never
@@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() {
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it }
}
/**
@@ -188,6 +201,31 @@ class MainActivity : AppCompatActivity() {
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
/**
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
* The new event's fields ride as CalendarContract extras; anything omitted
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
*/
private fun Intent.insertFormOrNull(): EventForm? {
if (action != Intent.ACTION_INSERT) return null
return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
title = getStringExtra(CalendarContract.Events.TITLE),
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
rrule = getStringExtra(CalendarContract.Events.RRULE),
zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(),
)
}
/** A Long extra's value, or null when the extra is absent. */
private fun Intent.longExtraOrNull(key: String): Long? =
if (hasExtra(key)) getLongExtra(key, 0L) else null
/**
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/

View File

@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
}
/**
* The next date on or after [today] carrying [month]/[day] — the date to sample
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
* ancient anchor (1972 for year-less dates), a year whose timezone rules
* (pre-DST offsets) differ from today's; sampling the offset there skews every
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
* the inherent limit). Feb-29 skips forward to the next leap year.
*/
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
var year = today.year
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
repeat(8) {
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
if (candidate != null && !candidate.isBefore(today)) return candidate
year++
}
// Unreachable for real month/day inputs; fall back to the raw anchor.
return LocalDate.of(today.year, month, day)
}
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it

View File

@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.data.calendar
/**
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
* `EVENT_COLOR`. The named entries exist for callers that need one specific
* hue (the managed special-dates calendars), so they can't drift from the
* swatches offered in the colour picker.
*/
object CalendarColorPalette {
val Red = 0xFFD50000.toInt()
val Orange = 0xFFE67C00.toInt()
val Amber = 0xFFF6BF26.toInt()
val Green = 0xFF33B679.toInt()
val DarkGreen = 0xFF0B8043.toInt()
val Blue = 0xFF039BE5.toInt()
val Indigo = 0xFF3F51B5.toInt()
val Purple = 0xFF8E24AA.toInt()
val Graphite = 0xFF616161.toInt()
/** The full palette, in swatch-row order. */
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
}

View File

@@ -24,10 +24,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
@@ -97,6 +101,55 @@ interface CalendarDataSource {
/** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long)
/**
* Create a local calendar tagged as the special-dates mirror for [type]
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
* local calendar, so it inherits per-calendar colour/visibility/reminders.
*/
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
/**
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
* detect when the user deleted one in system settings.
*/
fun findManagedCalendars(): List<ManagedCalendarRow>
/**
* The managed events in [calendarId] (those carrying a `UID_2445`, not
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
*/
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
/**
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
* seeds its reminder rows from [form] (never touched again by sync). Returns
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
/**
* Overwrite exactly the given managed columns on [eventId] (keys are
* `CalendarContract.Events` columns; a null value clears the column). A
* bare, targeted update — it never reconciles reminders or attendees, so
* user-owned data on the event survives every sync. No-ops on an empty map.
*/
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
/**
* Replace the reminders on **every managed** event in [calendarId] with
* [minutes] (all-day lead times), each encoded so it fires at
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
* special-dates section so a reminder change applies to existing events too,
* not just future ones — the managed calendars treat reminders as a
* calendar-level setting. Skips any non-managed (user) event in the calendar.
*/
fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
)
/**
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
* (minutes from local midnight) is the wall-clock time all-day reminders
@@ -292,6 +345,140 @@ class AndroidCalendarDataSource @Inject constructor(
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
}
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
// The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type))
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create managed calendar")
return ContentUris.parseId(uri)
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
"$MANAGED_MARKER_COLUMN IS NOT NULL",
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
val type = typeForMarker(c.getString(1)) ?: continue
add(ManagedCalendarRow(id = c.getLong(0), type = type))
}
}
} ?: emptyList()
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE,
),
// Only our own mirror events (UID prefix), so a user event that somehow
// landed in this calendar (e.g. an .ics import) is never seen as
// "existing but not desired" and deleted by the diff.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
add(
ManagedEventRow(
eventId = c.getLong(0),
uid = uid,
title = c.getString(2).orEmpty(),
dtStartMillis = c.getLong(3),
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
),
)
}
}
} ?: emptyList()
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
// Deterministic UID = the reconciliation key. A re-sync reads it back
// (queryManagedEvents) and diffs on it, so a contact never doubles.
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = buildEventInsertValues(form, uid, times)
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
?.let(ContentUris::parseId)
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
// Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. Encoded against the upcoming
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows(
eventId,
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
)
return eventId
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
}
override fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
// Only our own mirror events, so a stray user event in this calendar
// isn't all-day-re-encoded or stripped of its own reminders.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
reconcileReminders(
eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
)
}
}
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
@@ -575,35 +762,15 @@ class AndroidCalendarDataSource @Inject constructor(
.distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
// A globally-unique UID so a later .ics backup/restore can identify
// the event and not duplicate it on re-import (the provider leaves
// this null for events it didn't sync). Older rows without one fall
// back to a stable synthesised UID at export time (deriveIcsUid).
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
// The provider's invariant: recurring rows carry RRULE+DURATION
// (and no DTEND), one-off rows carry DTEND.
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
val values = buildEventInsertValues(
form,
uid = "${UUID.randomUUID()}@calendula",
times = form.toWriteTimes(ZoneId.systemDefault()),
).toContentValues().apply {
// A null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written.
when {
@@ -615,10 +782,25 @@ class AndroidCalendarDataSource @Inject constructor(
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri)
// Best effort (spec §8): the event exists at this point — a reminder
// that fails to attach is logged, not surfaced as a failed create.
encodedReminders(form, allDayReminderTimeMinutes)
.forEach { minutes ->
seedReminders(eventId, form, allDayReminderTimeMinutes)
// Guests are best-effort like reminders: a row that fails to attach is
// logged, not surfaced as a failed create. Calendula never sends an
// invitation — it only writes the rows; the backend decides delivery.
insertAttendees(eventId, form.attendees)
return eventId
}
/**
* Attach [form]'s reminders to a freshly inserted event. Best effort
* (spec §8): the event exists at this point — a reminder that fails to
* attach is logged, not surfaced as a failed create.
*/
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
@@ -628,11 +810,25 @@ class AndroidCalendarDataSource @Inject constructor(
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
// Guests are best-effort like reminders: a row that fails to attach is
// logged, not surfaced as a failed create. Calendula never sends an
// invitation — it only writes the rows; the backend decides delivery.
insertAttendees(eventId, form.attendees)
return eventId
}
/**
* A managed all-day event's reminders, encoded so they fire at
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
* it directly would sample a stale timezone offset, so the offset is sampled
* at the next occurrence of the anchor's month/day instead.
*/
private fun managedAllDayReminderMinutes(
anchor: LocalDate,
reminders: List<Int>,
allDayReminderTimeMinutes: Int,
): List<Int> {
val zone = ZoneId.systemDefault()
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
return reminders
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
.distinct()
}
override fun updateEvent(
@@ -1038,6 +1234,17 @@ class AndroidCalendarDataSource @Inject constructor(
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) {
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
}
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
private fun typeForMarker(marker: String?): SpecialDateType? =
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
private companion object {
const val TAG = "CalendarDataSource"
@@ -1047,6 +1254,14 @@ class AndroidCalendarDataSource @Inject constructor(
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
/**
* Column and namespace for the special-dates marker (shared with the
* calendar projection, which reads it into [CalendarSource.isManaged]).
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
*/
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
/**
* How far ahead/behind a search looks for a recurring event's nearest
* occurrence (~2 years). Wide enough for everyday series; a series that

View File

@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
} else {
null
},
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
// marker (only meaningful on the local calendars the app owns). This is
// the source of truth for the editor lock — independent of any stored
// preference that a backup restore could have wiped.
isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
)
}

View File

@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
}
/**
* Column values for a brand-new Events row: identity, times (the provider's
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
* carry DTEND), availability/access level and trimmed optional text. Shared by
* the user-event and managed-event insert paths, which differ only in the
* [uid] they stamp; colour and attendees are user-event concerns the caller
* layers on top.
*/
internal fun buildEventInsertValues(
form: EventForm,
uid: String,
times: EventWriteTimes,
): Map<String, Any?> = buildMap {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
/**
* Dirty-checked column values for updating an existing Events row: only what
* the user actually changed is written, so untouched fields can't stomp

View File

@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
/**
* One of the app's special-dates calendars, discovered by the marker stamped in
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
* stored ids in preferences were lost (e.g. an app-data wipe).
*/
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
/**
* A managed event as read back for the sync diff. [uid] is the deterministic
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
* the desired state to decide whether a targeted update is needed.
*/
data class ManagedEventRow(
val eventId: Long,
val uid: String,
val title: String,
val dtStartMillis: Long,
val rrule: String?,
)

View File

@@ -15,9 +15,17 @@ internal object CalendarProjection {
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
// so the mapper only reads it for local calendars).
DESCRIPTION_COLUMN,
// The special-dates marker (CAL_SYNC2) — the durable identity the app
// uses to recognise its own managed calendars, independent of any
// stored preference id (which a backup restore / data wipe can lose).
MANAGED_MARKER_COLUMN,
)
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
const val IDX_ID = 0
const val IDX_DISPLAY_NAME = 1
@@ -27,6 +35,7 @@ internal object CalendarProjection {
const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7
const val IDX_MANAGED_MARKER = 8
}
internal object InstanceProjection {

View File

@@ -0,0 +1,42 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import javax.inject.Inject
import javax.inject.Singleton
/**
* Resolves the localized calendar names/templates and per-type colours the
* mirror creates its calendars with. The colours are picked from the shared
* palette so managed calendars look native alongside user calendars.
*/
@Singleton
class AndroidSpecialDatesCalendarSpec @Inject constructor(
@ApplicationContext private val context: Context,
) : SpecialDatesCalendarSpec {
override fun displayName(type: SpecialDateType): String = context.getString(
when (type) {
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
},
)
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
when (type) {
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
},
)
override fun color(type: SpecialDateType): Int = when (type) {
SpecialDateType.Birthday -> CalendarColorPalette.Purple
SpecialDateType.Anniversary -> CalendarColorPalette.Red
SpecialDateType.Custom -> CalendarColorPalette.Blue
}
}

View File

@@ -0,0 +1,105 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Event
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
* device's contacts. Read-only and offline — the one-way source for the
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
* the permission is absent so the sync can degrade to a stalled state rather
* than crash.
*/
interface ContactSpecialDatesDataSource {
fun hasPermission(): Boolean
/** All usable contact special-dates, deduplicated per contact and type. */
fun readSpecialDates(): List<ContactSpecialDate>
}
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
else -> SpecialDateType.Custom
}
@Singleton
class AndroidContactSpecialDatesDataSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ContactSpecialDatesDataSource {
override fun hasPermission(): Boolean = context.hasContactsPermission()
override fun readSpecialDates(): List<ContactSpecialDate> {
if (!hasPermission()) return emptyList()
val resolver = context.contentResolver
// A contact can carry the same date more than once (multiple raw contacts
// under one aggregate); dedup on the mirror's reconciliation key so exact
// duplicates collapse while genuinely distinct dates (two custom events on
// one contact) are all kept. Ordered by Data._ID so which of two truly
// conflicting rows wins is stable across syncs (no event ping-pong).
val seen = HashSet<String>()
val result = ArrayList<ContactSpecialDate>()
runCatching {
resolver.query(
ContactsContract.Data.CONTENT_URI,
PROJECTION,
"${ContactsContract.Data.MIMETYPE} = ?",
arrayOf(Event.CONTENT_ITEM_TYPE),
"${ContactsContract.Data._ID} ASC",
)?.use { c ->
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
while (c.moveToNext()) {
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
val type = specialDateTypeForRawEventType(c.getInt(idxType))
val date = ContactSpecialDate(
lookupKey = lookup,
displayName = c.getString(idxName)?.trim().orEmpty(),
type = type,
month = parts.month,
day = parts.day,
year = parts.year,
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
)
if (seen.add(date.managedUid())) result += date
}
}
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
return result
}
private companion object {
const val TAG = "ContactSpecialDates"
val PROJECTION = arrayOf(
ContactsContract.Data.LOOKUP_KEY,
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
Event.START_DATE,
Event.TYPE,
Event.LABEL,
)
}
}

View File

@@ -0,0 +1,135 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
/**
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
* ContentObserver — it would need the process alive and buys almost nothing for
* once-a-year events. Everything stays offline (local contacts → local calendar).
*/
object SpecialDatesScheduler {
private const val WORK_NAME = "special-dates-sync"
private const val WORK_NAME_NOW = "special-dates-sync-now"
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
private const val KEY_FOREGROUND = "foreground"
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
fun apply(context: Context, enabled: Boolean) {
val workManager = WorkManager.getInstance(context)
if (!enabled) {
workManager.cancelUniqueWork(WORK_NAME)
workManager.cancelUniqueWork(WORK_NAME_NOW)
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
return
}
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
// Delay the first periodic run so it never overlaps an immediate run.
.setInitialDelay(1, TimeUnit.DAYS)
.build()
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
/**
* Run one reconcile immediately. [foreground] runs (the app resuming) are
* debounced inside the worker and use their own work name, so a frequent
* foreground resync can never REPLACE — and swallow — a pending enable /
* "Sync now" run, which always syncs. The engine serializes the two if they
* overlap.
*/
fun runNow(context: Context, foreground: Boolean = false) {
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
.build()
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
WorkManager.getInstance(context)
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
}
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
}
/**
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
* [EntryPoint] so it works under WorkManager's default worker factory. Records
* the run for the settings status line; a missing permission parks the feature
* in a stalled state (surfaced in settings) rather than retrying forever.
*/
class SpecialDatesSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun settingsPrefs(): SettingsPrefs
fun syncEngine(): SpecialDatesSyncEngine
}
override suspend fun doWork(): Result {
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
val prefs = deps.settingsPrefs()
val now = System.currentTimeMillis()
// A foreground resume can fire often — skip if we synced recently.
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
return Result.success()
}
return try {
when (val result = deps.syncEngine().sync()) {
// The engine re-checks the toggle, so already-queued work never
// revives a disabled feature; don't record a run either.
SpecialDatesSyncResult.Disabled -> return Result.success()
else -> {
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
SpecialDatesStalledReason.PermissionRevoked
} else {
null
}
prefs.recordSpecialDatesRun(now, stalled)
}
}
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
Result.success()
} catch (e: SecurityException) {
// A revoked calendar/contacts permission won't fix itself on retry —
// park the feature in a stalled state (surfaced in settings) instead
// of retrying with backoff forever.
Log.w(TAG, "Special-dates sync lacks a required permission", e)
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
Result.success()
} catch (e: Exception) {
Log.w(TAG, "Special-dates sync failed", e)
Result.retry()
}
}
companion object {
private const val TAG = "SpecialDatesSync"
/** Skip a foreground-triggered sync if one ran within this window (4h). */
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
}
}

View File

@@ -0,0 +1,304 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
import de.jeanlucmakiola.calendula.domain.toRRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
/**
* Localized, per-type presentation the engine needs but can't derive purely
* (calendar display name, colour, and the default title template). Kept behind
* an interface so the engine's diff stays unit-testable without Android
* resources.
*/
interface SpecialDatesCalendarSpec {
fun displayName(type: SpecialDateType): String
fun color(type: SpecialDateType): Int
fun defaultTitleTemplate(type: SpecialDateType): String
}
/**
* The stored title template for [type], falling back to the localized default
* when blank/absent — the one resolution rule shared by the sync (what gets
* written) and the settings editor (what gets shown).
*/
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
type: SpecialDateType,
stored: Map<SpecialDateType, String>,
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
/**
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
* fields once), changed contacts get a *targeted* managed-column update, and
* removed contacts are deleted — so a user's own edits (reminders, location,
* notes) are never clobbered. See docs/design/contact-special-dates.md.
*/
@Singleton
class SpecialDatesSyncEngine @Inject constructor(
private val contacts: ContactSpecialDatesDataSource,
private val calendars: CalendarDataSource,
private val prefs: SettingsPrefs,
private val spec: SpecialDatesCalendarSpec,
) {
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
// racing a "Sync now"/foreground run) would each see no managed calendar and
// both create one; a teardown racing an in-flight sync would delete calendars
// the sync then recreates. Holding this for the whole of sync()/teardown()
// makes those check-then-act sequences atomic, and — because sync() re-reads
// the enabled flag inside the lock — a teardown always wins the race.
private val lifecycleMutex = Mutex()
/**
* Reconcile every enabled type against the device's contacts. Returns why it
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
*/
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
val enabledTypes = prefs.specialDatesTypes.first()
val calendarByType = reconcileCalendars(enabledTypes)
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
val reminderCtx = readReminderContext()
val templates = prefs.specialDatesTitleTemplates.first()
val showYear = prefs.specialDatesShowYear.first()
calendarByType.forEach { (type, calendarId) ->
val template = spec.resolveTitleTemplate(type, templates)
syncType(
calendarId = calendarId,
type = type,
contactsOfType = desiredByType[type].orEmpty(),
template = template,
showYear = showYear,
reminderCtx = reminderCtx,
)
}
SpecialDatesSyncResult.Success
}
/**
* Set the reminder default for a type's managed calendar and apply it to
* **all** its existing events too (not just future ones) — for managed
* calendars the reminder is a calendar-level setting. Persists the per-calendar
* all-day override so new events keep matching. No-op if the calendar for
* [type] doesn't exist yet.
*/
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
prefs.setCalendarAllDayReminderOverride(calendarId, override)
val minutes = when (override) {
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
CalendarReminderOverride.None -> emptyList()
is CalendarReminderOverride.Minutes -> override.minutes
}
calendars.applyManagedCalendarReminders(
calendarId = calendarId,
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
minutes = minutes,
)
}
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
suspend fun teardown() = lifecycleMutex.withLock {
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
}
/**
* Ensure each enabled type has exactly one managed calendar (adopting an
* existing one, or the stored id if it still exists, else creating one) and
* that disabled types have none. Returns the calendar id per enabled type.
*/
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
val foundByType = calendars.findManagedCalendars()
.groupBy({ it.type }, { it.id })
.mapValues { it.value.first() }
val stored = prefs.specialDatesCalendars.first()
val result = LinkedHashMap<SpecialDateType, Long>()
for (type in SpecialDateType.entries) {
// A stored id counts only if the calendar still exists (the user may
// have deleted it in system settings); otherwise adopt a found one.
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
?: foundByType[type]
if (type in enabledTypes) {
val id = existingId ?: createCalendar(type)
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
result[type] = id
} else {
if (existingId != null) calendars.deleteCalendar(existingId)
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
}
}
return result
}
private suspend fun createCalendar(type: SpecialDateType): Long {
// reconcileCalendars persists the id — the single place ids are recorded.
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
// Seed a useful all-day reminder default (on the day + a week before), so
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
// only set when the user hasn't already configured this calendar.
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
prefs.setCalendarAllDayReminderOverride(
id,
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
)
}
return id
}
private suspend fun syncType(
calendarId: Long,
type: SpecialDateType,
contactsOfType: List<ContactSpecialDate>,
template: String,
showYear: Boolean,
reminderCtx: ReminderContext,
) {
val built = contactsOfType.map { sd ->
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
}
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
val formByUid = built.associate { it.desired.uid to it.form }
diff.insertUids.forEach { uid ->
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
}
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
}
private fun buildManagedEvent(
calendarId: Long,
type: SpecialDateType,
sd: ContactSpecialDate,
template: String,
showYear: Boolean,
reminderCtx: ReminderContext,
): BuiltManagedEvent {
val anchor = sd.anchorDate()
val start = LocalDateTime(anchor, LocalTime(0, 0))
// The source year is static and correct on every occurrence (unlike age).
val year = if (showYear) sd.year else null
val title = renderSpecialDateTitle(template, sd.displayName, year)
val form = EventForm(
calendarId = calendarId,
title = title,
isAllDay = true,
start = start,
end = start,
reminders = reminderCtx.resolveAllDay(calendarId),
availability = Availability.Free,
rrule = YEARLY_RRULE,
)
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
return BuiltManagedEvent(
desired = ManagedEventDesired(
uid = sd.managedUid(),
title = title,
dtStartMillis = dtStartMillis,
rrule = YEARLY_RRULE,
),
form = form,
)
}
// Managed events are always all-day, so only the all-day defaults apply.
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
)
private data class ReminderContext(
val allDayGlobal: List<Int>,
val allDayOverrides: Map<Long, List<Int>>,
val allDayTimeMinutes: Int,
) {
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
fun resolveAllDay(calendarId: Long): List<Int> =
allDayOverrides[calendarId] ?: allDayGlobal
}
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
private companion object {
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
}
}
/** The managed columns of a desired event, compared against the existing row. */
internal data class ManagedEventDesired(
val uid: String,
val title: String,
val dtStartMillis: Long,
val rrule: String?,
)
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
internal data class ManagedDiff(
val insertUids: List<String>,
val updates: List<ManagedFieldUpdate>,
val deleteEventIds: List<Long>,
)
/**
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
* desired-not-existing → insert, existing-not-desired → delete, and for events
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
* so a re-run with no contact changes produces nothing, and a managed update
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
*/
internal fun diffManagedEvents(
desired: List<ManagedEventDesired>,
existing: List<ManagedEventRow>,
): ManagedDiff {
val desiredByUid = desired.associateBy { it.uid }
val existingByUid = existing.associateBy { it.uid }
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
val updates = buildList {
for (d in desired) {
val row = existingByUid[d.uid] ?: continue
val columns = buildMap<String, Any?> {
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
if (row.dtStartMillis != d.dtStartMillis) {
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
}
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
}
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
}
}
return ManagedDiff(insertUids, updates, deleteEventIds)
}

View File

@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
import kotlinx.coroutines.CoroutineDispatcher
@@ -45,6 +49,18 @@ abstract class DataBindModule {
abstract fun bindReminderAlertStore(
impl: AndroidReminderAlertStore,
): ReminderAlertStore
@Binds
@Singleton
abstract fun bindContactSpecialDatesDataSource(
impl: AndroidContactSpecialDatesDataSource,
): ContactSpecialDatesDataSource
@Binds
@Singleton
abstract fun bindSpecialDatesCalendarSpec(
impl: AndroidSpecialDatesCalendarSpec,
): SpecialDatesCalendarSpec
}
@Module

View File

@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -513,6 +514,95 @@ class SettingsPrefs @Inject constructor(
}
}
// --- Contact special dates (issue #15) ------------------------------
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
}
/** The date types the user wants mirrored (default: all three). */
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
}
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
store.edit { it[typeEnabledKey(type)] = enabled }
}
/** The managed calendar id per type, for the ones that currently exist. */
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
SpecialDateType.entries.mapNotNull { type ->
prefs[calendarIdKey(type)]?.let { type to it }
}.toMap()
}
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
store.edit { prefs ->
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
}
}
/**
* The title template per type ("{name}'s birthday"); an empty string means
* unset, so the settings screen can seed the localized default. `{name}` is
* the contact name, `{age}` the age at the upcoming date (empty when the
* birth year is unknown or [specialDatesShowAge] is off).
*/
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
}
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
store.edit { it[titleTemplateKey(type)] = template }
}
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
}
/** Outcome of the last mirror sync, for the settings status line. */
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
SpecialDatesStatus(
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
)
}
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
store.edit { prefs ->
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
}
}
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
val specialDatesLastForegroundSync: Flow<Long> =
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
}
private fun typeEnabledKey(type: SpecialDateType) =
booleanPreferencesKey("special_dates_type_${type.name}")
private fun calendarIdKey(type: SpecialDateType) =
longPreferencesKey("special_dates_calendar_${type.name}")
private fun titleTemplateKey(type: SpecialDateType) =
stringPreferencesKey("special_dates_title_${type.name}")
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
@@ -605,9 +695,30 @@ class SettingsPrefs @Inject constructor(
const val DEFAULT_BACKUP_INTERVAL = 1_440L
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
const val MIN_BACKUP_INTERVAL = 30L
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
longPreferencesKey("special_dates_last_foreground_sync")
}
}
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
data class SpecialDatesStatus(
/** Epoch millis of the last sync, or 0 if it has never run. */
val lastRun: Long,
/** Non-null when the mirror is paused and why; null when healthy. */
val stalled: SpecialDatesStalledReason?,
)
/** Why the special-dates mirror is paused. */
enum class SpecialDatesStalledReason {
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
PermissionRevoked,
}
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
data class BackupStatus(
/** Epoch millis of the last run, or 0 if it has never run. */
@@ -626,6 +737,18 @@ sealed interface CalendarReminderOverride {
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
}
/**
* The stored override for [calendarId] in an override map, as a
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
* empty → [CalendarReminderOverride.None]) — the inverse of how
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
*/
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
}
/**
* The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
* Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
* #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
* calendar this way to create a new event, passing the fields as
* [android.provider.CalendarContract] extras. Any field the intent omits falls
* back to the same defaults the in-app "new event" uses — a timed start at the
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
* it resolves to the last-used / first-writable calendar, exactly like the
* `.ics` single-event and plain new-event paths.
*
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
* the extras lives in `MainActivity.insertFormOrNull`.
*/
fun buildInsertEventForm(
beginMillis: Long?,
endMillis: Long?,
isAllDay: Boolean,
title: String?,
description: String?,
location: String?,
rrule: String?,
zone: TimeZone,
now: Instant,
): EventForm {
val (start, end) = if (isAllDay) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
val startDate = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?: now.toLocalDateTime(zone).date
val endDate = endMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
val startTime = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?: nextFullHour(now, zone)
val endTime = endMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?.takeIf { it >= startTime }
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
startTime to endTime
}
return EventForm(
calendarId = null,
title = title.orEmpty(),
isAllDay = isAllDay,
start = start,
end = end,
location = location.orEmpty(),
description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
// some callers include.
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)
}
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
}

View File

@@ -26,6 +26,14 @@ data class CalendarSource(
* owns for its own calendars). Always null for synced calendars.
*/
val description: String? = null,
/**
* A special-dates mirror calendar the app manages (birthdays/anniversaries
* from contacts). Its events' title/date/recurrence are owned by the sync,
* so it's hidden from the new-event calendar picker and its events lock those
* fields in the editor. Recognised by a durable provider marker, so it holds
* even after a backup restore clears the app's stored ids.
*/
val isManaged: Boolean = false,
)
data class EventInstance(

View File

@@ -0,0 +1,122 @@
package de.jeanlucmakiola.calendula.domain.contacts
import java.time.LocalDate
/**
* The kinds of contact "special date" Calendula mirrors into local calendars.
* Each kind gets its own local calendar, so it inherits per-calendar colour,
* visibility and reminder defaults for free. See
* docs/design/contact-special-dates.md.
*/
enum class SpecialDateType {
Birthday,
Anniversary,
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
Custom,
}
/**
* One dated event read from a device contact (a `ContactsContract` `Event`
* row). The [lookupKey] is the stable contact identity used to reconcile the
* mirror without duplicating; [year] is null when the contact stored the date
* without one (`--MM-dd`), in which case age can't be shown.
*/
data class ContactSpecialDate(
val lookupKey: String,
val displayName: String,
val type: SpecialDateType,
val month: Int,
val day: Int,
val year: Int?,
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
val label: String? = null,
)
/** The parsed month/day (+ optional year) of a contact date. */
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
/**
* A fixed leap year to validate and anchor year-less dates against, so that a
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
* years — matching how the calendar provider expands a yearly Feb-29 series).
*/
const val YEARLESS_ANCHOR_YEAR = 1972
/**
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
* calendar parts, or null if it isn't a usable date. Handles the three shapes
* seen in the wild:
* - full `yyyy-MM-dd` (year known),
* - year-less `--MM-dd` (year null),
* - compact `yyyyMMdd`.
*
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
* it's unit-tested without a device.
*/
fun parseContactEventDate(raw: String?): ContactDateParts? {
val s = raw?.trim().orEmpty()
if (s.isEmpty()) return null
// Year-less: "--MM-dd" or "--MMdd".
if (s.startsWith("--")) {
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
return validated(null, m, d)
}
if (s.contains('-')) {
val parts = s.split('-').filter { it.isNotEmpty() }
return when (parts.size) {
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
// first part, so require the first token to look like a year.
3 -> if (s.startsWith('-')) null else validated(
year = parts[0].toIntOrNull() ?: return null,
month = parts[1].toIntOrNull() ?: return null,
day = parts[2].toIntOrNull() ?: return null,
)
// Year-less "MM-dd" without the "--" prefix.
2 -> validated(
year = null,
month = parts[0].toIntOrNull() ?: return null,
day = parts[1].toIntOrNull() ?: return null,
)
else -> null
}
}
// Compact "yyyyMMdd".
if (s.length == 8 && s.all { it.isDigit() }) {
return validated(
year = s.substring(0, 4).toInt(),
month = s.substring(4, 6).toInt(),
day = s.substring(6, 8).toInt(),
)
}
return null
}
/** "MM-dd" or compact "MMdd". */
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
if (rest.contains('-')) {
val p = rest.split('-').filter { it.isNotEmpty() }
if (p.size != 2) return null
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
}
if (rest.length == 4 && rest.all { it.isDigit() }) {
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
}
return null
}
/**
* Confirm month/day form a real calendar date (validated against the actual
* year when known, otherwise the leap anchor so `02-29` survives).
*/
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
if (month !in 1..12 || day !in 1..31) return null
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
return runCatching { LocalDate.of(checkYear, month, day) }
.map { ContactDateParts(year, month, day) }
.getOrNull()
}

View File

@@ -0,0 +1,65 @@
package de.jeanlucmakiola.calendula.domain.contacts
import kotlinx.datetime.LocalDate
/**
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
* only ever reconciles — and never deletes — events it actually owns.
*/
const val MANAGED_UID_PREFIX = "contact-"
/**
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
* contact date. Stable across syncs (the reconciliation key), namespaced by
* type so the same contact's birthday and anniversary never collide, and — when
* a [discriminator] is given — by it too, so two Custom dates on one contact
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
* each other.
*/
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
}
/**
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
* date adds a discriminator (its label, else its month-day) so distinct custom
* dates on one contact don't collapse into a single event.
*/
fun ContactSpecialDate.managedUid(): String =
managedEventUid(type, lookupKey, customDiscriminator())
private fun ContactSpecialDate.customDiscriminator(): String? =
if (type == SpecialDateType.Custom) {
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
} else {
null
}
/**
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
* date when the year is known (so age can be derived), otherwise the month/day
* on a fixed leap anchor year so a `--02-29` date stays representable and the
* anchor never drifts between syncs.
*/
fun ContactSpecialDate.anchorDate(): LocalDate =
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
/**
* Render a title [template] for a contact, substituting `{name}` and `{year}`
* (the source year — a birthday's birth year or an anniversary's start year;
* empty when [year] is null). Unlike an age, the year is static and correct on
* every occurrence of the yearly event. Collapses the whitespace an empty
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
* "Jane's birthday" when no year is known.
*/
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
template
.replace("{name}", name)
.replace("{year}", year?.toString().orEmpty())
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
.replace(Regex("""\(\s*\)"""), "")
.replace(Regex("""\s+"""), " ")
.trim()

View File

@@ -72,6 +72,8 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
onInsertConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
// Wait for the persisted default view before seeding the stack, so the app
@@ -176,6 +178,16 @@ fun CalendarHost(
onImportConsumed()
}
}
// An external ACTION_INSERT launch (another app/widget creating an event,
// issue #30) arrives already prefilled — open it in the same create form the
// single-event .ics path uses. [importForm] is the topmost overlay, so it
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
importForm = requestedInsertForm
onInsertConsumed()
}
}
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on

View File

@@ -19,6 +19,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@@ -33,6 +35,8 @@ fun RootScreen(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
onInsertConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -49,6 +53,12 @@ fun RootScreen(
hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
// Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it.
if (context.hasContactsPermission()) {
SpecialDatesScheduler.runNow(context, foreground = true)
}
}
}
lifecycle.addObserver(obs)
@@ -76,6 +86,8 @@ fun RootScreen(
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,

View File

@@ -94,7 +94,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
@@ -142,7 +142,7 @@ fun CalendarsScreen(
sessionKey = editorSession,
isNew = editorId == NEW_CALENDAR_ID,
initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
initialDescription = editing?.description.orEmpty(),
onSave = { name, color, description ->
val id = editorId
@@ -507,7 +507,7 @@ private fun CalendarEditor(
)
Spacer(Modifier.height(12.dp))
ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE,
colors = CalendarColorPalette.all,
selected = color,
onSelect = { color = it },
dark = dark,

View File

@@ -67,16 +67,3 @@ fun ColorSwatchRow(
}
}
}
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
0xFFD50000, // red
0xFFE67C00, // orange
0xFFF6BF26, // amber
0xFF33B679, // green
0xFF0B8043, // dark green
0xFF039BE5, // blue
0xFF3F51B5, // indigo
0xFF8E24AA, // purple
0xFF616161, // graphite
).map { it.toInt() }

View File

@@ -36,6 +36,7 @@ fun InlineTextField(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
imeAction: ImeAction = ImeAction.Default,
@@ -43,15 +44,17 @@ fun InlineTextField(
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
color = when {
// A disabled field reads as dimmed, like a locked value.
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
textStyle.color.isSpecified -> textStyle.color
else -> MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,

View File

@@ -250,6 +250,9 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_edit),
)
}
// Managed special-dates events have no delete — the sync
// would resurrect them; the date is removed at the contact.
if (!s.isManaged) {
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
@@ -260,6 +263,7 @@ fun EventDetailScreen(
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,

View File

@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
val calendarName: String?,
/** Whether the owning calendar allows modifying events (shows edit/delete). */
val canModify: Boolean = false,
/**
* The event belongs to a managed special-dates calendar: its editable
* fields (reminders, notes) can still be edited, but delete is hidden —
* the sync would just resurrect it. Contact dates are removed at the source.
*/
val isManaged: Boolean = false,
) : EventDetailUiState
}

View File

@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
isManaged = calendar?.isManaged == true,
)
} catch (e: CancellationException) {
throw e

View File

@@ -120,7 +120,7 @@ import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
@@ -439,6 +439,10 @@ private fun EventEditContent(
val form = state.form
val locale = currentLocale()
val dark = isSystemInDarkTheme()
// The title, date and recurrence are managed by the special-dates sync, so
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
val locked = state.isManaged
var picker by remember { mutableStateOf<PickerTarget?>(null) }
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -523,6 +527,7 @@ private fun EventEditContent(
placeholder = stringResource(R.string.event_edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium
.copy(fontWeight = FontWeight.SemiBold),
enabled = !locked,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
@@ -535,6 +540,17 @@ private fun EventEditContent(
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
if (locked) {
Spacer(Modifier.height(10.dp))
Text(
text = stringResource(
R.string.event_edit_managed_hint,
selectedCalendar?.displayName.orEmpty(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(20.dp))
@@ -553,7 +569,11 @@ private fun EventEditContent(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
Switch(
checked = form.isAllDay,
onCheckedChange = viewModel::setAllDay,
enabled = !locked,
)
}
},
) {
@@ -565,6 +585,7 @@ private fun EventEditContent(
locale = locale,
onDateClick = { picker = PickerTarget.StartDate },
onTimeClick = { picker = PickerTarget.StartTime },
enabled = !locked,
)
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
@@ -574,6 +595,7 @@ private fun EventEditContent(
onDateClick = { picker = PickerTarget.EndDate },
onTimeClick = { picker = PickerTarget.EndTime },
isError = EventFormProblem.EndBeforeStart in state.problems,
enabled = !locked,
)
if (EventFormProblem.EndBeforeStart in state.problems) {
Spacer(Modifier.height(2.dp))
@@ -704,7 +726,7 @@ private fun EventEditContent(
EditCard(
icon = Icons.Default.Repeat,
iconContentDescription = null,
onClick = { showRecurrencePicker = true },
onClick = if (locked) null else ({ showRecurrencePicker = true }),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1727,7 +1749,7 @@ private fun ColorPickerDialog(
)
} else {
ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE,
colors = CalendarColorPalette.all,
selected = selected,
onSelect = onPickRaw,
dark = dark,
@@ -1862,6 +1884,7 @@ private fun InlineField(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
modifier: Modifier = Modifier
.fillMaxWidth()
@@ -1875,6 +1898,7 @@ private fun InlineField(
textStyle = textStyle,
singleLine = singleLine,
minLines = minLines,
enabled = enabled,
keyboardType = keyboardType,
)
}
@@ -1889,6 +1913,7 @@ private fun ScheduleRow(
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
isError: Boolean = false,
enabled: Boolean = true,
) {
val dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -1898,11 +1923,12 @@ private fun ScheduleRow(
timeOfDayFormatter(use24Hour, locale)
}
// Tappable values read as links (primary), like the location on the
// detail screen; errors flip them to the error colour.
val valueColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
// detail screen; errors flip them to the error colour; a locked (managed)
// row dims to a plain, non-tappable value.
val valueColor = when {
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
isError -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1919,7 +1945,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(onClick = onDateClick)
.clickable(enabled = enabled, onClick = onDateClick)
.padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!isAllDay) {
@@ -1928,7 +1954,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(onClick = onTimeClick)
.clickable(enabled = enabled, onClick = onTimeClick)
.padding(vertical = 8.dp, horizontal = 6.dp),
)
}

View File

@@ -29,6 +29,12 @@ data class EventEditUiState(
val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false,
/**
* True when the event's calendar is a contact special-dates calendar the app
* manages. While editing such an event the title, date and recurrence are
* locked (sync overwrites them); reminders, location and notes stay editable.
*/
val isManaged: Boolean = false,
/**
* Whether to focus the title and raise the keyboard when this is a fresh
* create form (issue #10). Mirrors the settings flag; the screen only acts

View File

@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
val autofocusTitle: Boolean,
)
/** Every calendar the provider exposes; the source for both lists below. */
private val allCalendars: Flow<List<CalendarSource>> =
repository.calendars().catch { emit(emptyList()) }
/**
* Writable calendars — the only valid event targets. Disabled calendars are
* excluded, so you can't create into a calendar you've removed from the app;
* a last-used preselect landing on a now-disabled 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.
*/
private val writableCalendars: Flow<List<CalendarSource>> = combine(
repository.calendars(),
allCalendars,
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
}
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(
@@ -165,21 +171,33 @@ class EventEditViewModel @Inject constructor(
::ExternalInputs,
).flowOn(io),
colorPalette,
) { local, external, palette ->
allCalendars,
) { local, external, palette, allCalendars ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
// Editing an event in a managed calendar locks its synced fields. Keyed
// off the calendar's durable marker, not a stored id, so it holds after a
// backup restore too.
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
// The picker offers writable calendars only; when editing a managed event
// its own (excluded) calendar is added back so the row still names it.
val pickerCalendars =
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
else external.writable
val visibleFields = external.defaultFields + local.revealed
EventEditUiState(
form = resolved,
calendars = external.writable,
calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
isManaged = isManaged,
autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change.
@@ -417,7 +435,10 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Saved
return
}
if (target != null && target.original.rrule != null) {
// Managed events are a yearly series whose editable fields (reminders,
// notes, location) live on the series row — never offer the scope dialog,
// which would split the series into an exception the sync then reverts.
if (target != null && target.original.rrule != null && !current.isManaged) {
_saveState.value = SaveUiState.AwaitingScope
return
}

View File

@@ -39,7 +39,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Check
@@ -91,13 +93,16 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
@@ -111,6 +116,7 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
@@ -139,7 +145,7 @@ import java.time.format.TextStyle as JavaTextStyle
import java.util.Calendar
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, Views, EventForm, Notifications }
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates }
/**
* Token-based accent for a leading icon chip (container / on-container pair).
@@ -203,7 +209,19 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
NotificationsScreen(
state = state,
viewModel = viewModel,
onBack = { section = null },
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
)
}
AnimatedVisibility(
visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
}
}
}
@@ -257,6 +275,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
onClick = onManageCalendars,
)
GroupedRow(
title = stringResource(R.string.settings_section_special_dates),
summary = stringResource(R.string.settings_special_dates_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.SpecialDates) },
)
LanguageRow(position = Position.Middle)
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
@@ -985,6 +1010,7 @@ private fun NotificationsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
@@ -1109,6 +1135,25 @@ private fun NotificationsScreen(
Column {
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// A contact special-dates calendar owns its reminders in
// its own section — link there instead of an override.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName,
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
return@forEach
}
val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list
// of three (the card + the timed and all-day rows).
@@ -1231,6 +1276,287 @@ private fun NotificationsScreen(
}
}
// ---------------------------------------------------------------------------
// Contact special dates (issue #15)
// ---------------------------------------------------------------------------
@Composable
private fun SpecialDatesScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
val context = LocalContext.current
// READ_CONTACTS is requested only here, on enable — never at startup. On a
// grant we either enable (if turning on) or just re-sync (clearing a stalled
// banner after the permission was re-granted).
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
}
}
val requestOrEnable: () -> Unit = {
if (context.hasContactsPermission()) {
viewModel.setSpecialDatesEnabled(true)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
var confirmDisableAll by remember { mutableStateOf(false) }
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_special_dates),
onBack = onBack,
) {
// Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.settings_special_dates_paused_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.settings_special_dates_paused_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
) { Text(stringResource(R.string.settings_special_dates_grant)) }
}
}
Spacer(Modifier.height(16.dp))
}
GroupedRow(
title = stringResource(R.string.settings_special_dates_enable),
summary = stringResource(R.string.settings_special_dates_enable_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.enabled,
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
)
},
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
)
if (state.enabled) {
// Per-type card: the toggle, and while on, its title format and its
// calendar-wide reminder.
SpecialDateType.entries.forEachIndexed { index, type ->
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
val on = type in state.types
GroupedRow(
title = stringResource(specialDateTypeLabel(type)),
position = if (on) Position.Top else Position.Alone,
trailing = {
Switch(
checked = on,
onCheckedChange = { want ->
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
else confirmDisableType = type
},
)
},
onClick = {
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
},
)
if (on) {
GroupedRow(
title = stringResource(R.string.settings_special_dates_template),
summary = state.titleTemplates[type].orEmpty(),
position = Position.Middle,
onClick = { editTemplate = type },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_reminders),
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
position = Position.Bottom,
onClick = { reminderPickerType = type },
)
}
}
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_special_dates_show_year),
summary = stringResource(R.string.settings_special_dates_show_year_hint),
position = Position.Top,
trailing = {
Switch(
checked = state.showYear,
onCheckedChange = viewModel::setSpecialDatesShowYear,
)
},
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_sync_now),
summary = specialDatesLastRunLabel(context, state.lastRun),
position = Position.Bottom,
onClick = viewModel::syncSpecialDatesNow,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.settings_special_dates_calendar_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
if (confirmDisableAll) {
SpecialDatesDisableDialog(
message = stringResource(R.string.settings_special_dates_disable_all_message),
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
onDismiss = { confirmDisableAll = false },
)
}
confirmDisableType?.let { type ->
SpecialDatesDisableDialog(
message = stringResource(
R.string.settings_special_dates_disable_type_message,
stringResource(specialDateTypeLabel(type)),
),
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
onDismiss = { confirmDisableType = null },
)
}
editTemplate?.let { type ->
SpecialDatesTemplateDialog(
initial = state.titleTemplates[type].orEmpty(),
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
onDismiss = { editTemplate = null },
)
}
reminderPickerType?.let { type ->
ReminderDefaultPicker(
title = stringResource(R.string.settings_special_dates_reminders),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
// Managed calendars own their reminders outright — no "inherit global".
allowInherit = false,
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
onDismiss = { reminderPickerType = null },
)
}
}
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
@Composable
private fun SpecialDatesDisableDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
text = { Text(message) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = onConfirm) {
Text(stringResource(R.string.settings_special_dates_disable_confirm))
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun SpecialDatesTemplateDialog(
initial: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var text by rememberSaveable { mutableStateOf(initial) }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_special_dates_template)) },
text = {
Column {
// The app's borderless input over a tonal surface (the dialog
// convention — see DialogControls), not Material's outlined field.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = text,
onValueChange = { text = it },
placeholder = stringResource(R.string.settings_special_dates_template),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.settings_special_dates_template_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { onConfirm(text) },
enabled = text.isNotBlank(),
) { Text(stringResource(R.string.dialog_save)) }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
}
@Composable
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
if (lastRun <= 0L) {
stringResource(R.string.settings_special_dates_never_synced)
} else {
stringResource(
R.string.settings_special_dates_last_synced,
android.text.format.DateUtils.getRelativeTimeSpanString(
lastRun,
System.currentTimeMillis(),
android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString(),
)
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
@@ -1314,22 +1640,11 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
return DateFormat.getTimeFormat(context).format(time)
}
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
}
/** Label for a global-default choice: empty → "None", else the lead times joined. */
@Composable
private fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
// than a (non-composable) joinToString transform.
val parts = ArrayList<String>(minutes.size)
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
return parts.joinToString(", ")
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
}
/** Row summary for a calendar: its override, or the inherited global default. */

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
@@ -7,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -75,4 +77,30 @@ data class SettingsUiState(
* colour palette (the colour may then not survive their next sync).
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
/**
* Ids of the contact special-dates calendars. Their reminders are owned by
* the Contact special dates section, so the per-calendar override list links
* out to it instead of offering an override here.
*/
val managedCalendarIds: Set<Long> = emptySet(),
)
/**
* State for the contact special-dates sub-page (issue #15). Exposed as its own
* flow rather than folded into [SettingsUiState] (whose combine is already full).
* [titleTemplates] is always populated — a blank stored template resolves to the
* localized default so the editor never shows an empty field.
*/
data class SpecialDatesUiState(
val enabled: Boolean = false,
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
/** The reminder choice per type's managed calendar (applied to all its events). */
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
/** Whether {year} resolves in titles (the source year is static and always correct). */
val showYear: Boolean = true,
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
val stalledPermission: Boolean = false,
/** Epoch millis of the last sync, or 0 if it has never run. */
val lastRun: Long = 0L,
)

View File

@@ -11,17 +11,24 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -52,6 +59,8 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -98,8 +107,9 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
prefs.managedCalendarIds,
) { overrides, allDayOverrides, calendars, managedIds ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
},
combine(
prefs.defaultView,
@@ -144,6 +154,7 @@ class SettingsViewModel @Inject constructor(
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds,
)
}.stateIn(
scope = viewModelScope,
@@ -192,6 +203,7 @@ class SettingsViewModel @Inject constructor(
val timed: Map<Long, List<Int>>,
val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>,
val managedIds: Set<Long>,
)
private data class ViewSettings(
@@ -215,6 +227,95 @@ class SettingsViewModel @Inject constructor(
val drawerOrder: List<CalendarView>,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
combine(
prefs.specialDatesEnabled,
prefs.specialDatesTypes,
prefs.specialDatesTitleTemplates,
prefs.specialDatesShowYear,
prefs.specialDatesStatus,
) { enabled, types, templates, showYear, status ->
SpecialDatesUiState(
enabled = enabled,
types = types,
// A blank stored template resolves to the localized default so the
// editor always shows something sensible.
titleTemplates = SpecialDateType.entries.associateWith { type ->
specialDatesSpec.resolveTitleTemplate(type, templates)
},
showYear = showYear,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun,
)
},
prefs.specialDatesCalendars,
prefs.perCalendarAllDayReminderOverride,
) { base, calendars, allDayOverrides ->
base.copy(
// An override is always seeded on creation; present-empty = None.
reminderChoices = calendars.mapValues { (_, calendarId) ->
allDayOverrides.choiceFor(calendarId)
},
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SpecialDatesUiState(),
)
/**
* Turn the mirror on or off. Enabling schedules the daily job and runs an
* immediate sync (which creates the calendars); disabling cancels the job and
* tears the managed calendars down. The caller must have READ_CONTACTS granted
* before enabling.
*/
fun setSpecialDatesEnabled(enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDatesEnabled(enabled)
if (enabled) {
SpecialDatesScheduler.apply(appContext, enabled = true)
SpecialDatesScheduler.runNow(appContext)
} else {
SpecialDatesScheduler.apply(appContext, enabled = false)
withContext(io) { specialDatesEngine.teardown() }
}
}
}
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDateTypeEnabled(type, enabled)
// Reconcile now: create the new type's calendar, or delete a disabled one.
SpecialDatesScheduler.runNow(appContext)
}
}
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
viewModelScope.launch {
prefs.setSpecialDatesTitleTemplate(type, template.trim())
SpecialDatesScheduler.runNow(appContext)
}
}
fun setSpecialDatesShowYear(enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDatesShowYear(enabled)
SpecialDatesScheduler.runNow(appContext)
}
}
/**
* Set a type's managed-calendar reminder default and apply it to all its
* existing events (managed calendars own their reminders calendar-wide).
*/
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
}
/** Kick off an immediate reconcile (the "Sync now" action). */
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}

View File

@@ -6,4 +6,416 @@
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Un calendario moderno.</string>
<string name="state_loading">Cargando…</string>
<string name="state_retry">Vuelve a intentarlo</string>
<string name="state_failure_unknown">Algo salio mal.</string>
<string name="state_failure_permission">Se requiere acceso al calendario.</string>
<string name="state_failure_permission_action">Permite el acceso</string>
<string name="state_failure_no_calendars">No se han configurado calendarios.</string>
<string name="state_failure_no_calendars_action">Abrir los ajustes del calendario del sistema</string>
<string name="state_failure_provider">No se pudo leer el calendario.</string>
<string name="permission_rationale_title">Mira todos sus eventos, a la perfección</string>
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá.</string>
<string name="permission_request_button">Conceder el acceso al calendario</string>
<string name="permission_denied_title">Acceso a calendario denegado</string>
<string name="permission_denied_body">Calendula no puede mostrar eventos sin acceso al calendario. Puedes concederlo en ajustes del sistema.</string>
<string name="permission_open_settings_button">Abrir ajustes del sistema</string>
<string name="permission_retry_button">Intentarlo de nuevo</string>
<string name="permission_benefit_private_title">Permanece en tu dispositivo</string>
<string name="permission_benefit_private_body">Tus calendarios se leen localmente y nunca abandonan tu celular.</string>
<string name="permission_benefit_sync_title">Todos tus calendarios, juntos</string>
<string name="permission_benefit_sync_body">Google, CalDAV, local — todo lo sincronizado en el dispositivo aparecerá.</string>
<string name="permission_benefit_privacy_title">Sin rastreo, jamás</string>
<string name="permission_benefit_privacy_body">Sin telemetría, sin analíticas, sin anuncios.</string>
<string name="permission_privacy_footnote">Permanece en tu dispositivo, no necesita acceso a internet</string>
<string name="month_prev">Mes anterior</string>
<string name="month_next">Siguiente mes</string>
<string name="month_today_action">Hoy</string>
<string name="month_more_actions">Mas acciones</string>
<string name="month_open_menu">Abrir menu</string>
<string name="month_action_settings">Ajustes</string>
<string name="month_a11y_today_prefix">Hoy</string>
<string name="week_today_action">Esta semana</string>
<string name="week_number_label">Sem</string>
<string name="day_today_action">Hoy</string>
<string name="event_detail_back">Atras</string>
<string name="event_detail_edit">Editar</string>
<string name="event_detail_delete">Eliminar</string>
<string name="event_detail_share">Compartir</string>
<string name="event_share_chooser_title">Compartir evento</string>
<string name="event_share_failed">No se pudo compartir el evento.</string>
<string name="event_delete_title">¿Eliminar evento?</string>
<string name="event_delete_body">El evento se elimino de tu calendario y de todos los dispositivos con los cuales se sincroniza.</string>
<string name="event_delete_recurring_title">Eliminar evento recurrente</string>
<string name="event_delete_option_occurrence">Solo este evento</string>
<string name="event_delete_option_following">Este y todos los eventos siguientes</string>
<string name="event_delete_option_series">Todos los eventos en la serie</string>
<string name="event_edit_recurring_title">Editar evento recurrente</string>
<string name="event_delete_failed">No se pudo eliminar el evento</string>
<string name="event_delete_write_denied">Calendula necesita acceso de escritura para eliminar eventos</string>
<string name="dialog_cancel">Cancelar</string>
<string name="dialog_ok">Okey</string>
<string name="event_edit_new_title">Nuevo evento</string>
<string name="event_edit_close">Cerrar</string>
<string name="event_edit_save">Guardar</string>
<string name="event_edit_title_hint">Añadir título</string>
<string name="event_edit_starts">Inicio</string>
<string name="event_edit_ends">Final</string>
<string name="event_edit_error_end_before_start">Termina antes de comenzar</string>
<string name="event_edit_error_no_calendar">No hay ningún calendario en donde se pueda escribir disponible</string>
<string name="event_edit_save_failed">No se pudo guardar el evento</string>
<string name="event_edit_write_denied">Calendula necesita acceso de escritura para crear eventos</string>
<string name="event_edit_more_fields">Mas campos</string>
<string name="event_edit_add">Añadir</string>
<string name="event_edit_add_reminder">Añadir recordatorio</string>
<string name="event_edit_remove_reminder">Eliminar recordatorio</string>
<string name="event_edit_attendees">Invitados</string>
<string name="event_edit_add_guest">Añadir invitados</string>
<string name="event_edit_add_guest_hint">Añadir invitados por correo…</string>
<string name="event_edit_add_guest_from_contacts">Añadir desde contactos</string>
<string name="event_edit_location_from_contacts">Seleccionar dirección desde contactos</string>
<string name="event_edit_remove_guest">Eliminar invitado</string>
<string name="event_edit_attendee_required">Obligatorio</string>
<string name="event_edit_attendee_optional">Opcional</string>
<string name="event_edit_attendees_note_synced">Calendula no envia invitaciones. Tu cuenta de calendario puede enviar correos a los invitados cuando se sincronice.</string>
<string name="event_edit_attendees_note_local">Guardado en el dispositivo. No se avisa a nadie.</string>
<string name="event_edit_reminder_custom">Personalizado</string>
<string name="reminder_unit_minutes">minutos</string>
<string name="reminder_unit_hours">horas</string>
<string name="reminder_unit_days">días</string>
<string name="reminder_unit_weeks">semanas</string>
<string name="event_edit_availability">Disponibilidad</string>
<string name="event_edit_visibility">Visibilidad</string>
<string name="event_edit_color">Color</string>
<string name="event_edit_color_default">Color del calendario</string>
<string name="event_edit_color_custom">Color personalizado</string>
<string name="event_edit_color_reset">Reiniciar</string>
<string name="event_edit_color_unsupported">No disponible para este calendario</string>
<string name="event_edit_color_unsupported_hint">Este calendario no admite un ser de colores. Puedes permitir colores personalizados para este tipo de calendarios en ajustes.</string>
<string name="event_edit_color_sync_warning">Este calendario puede eliminar o cambiar el color la próxima vez que se sincronice.</string>
<string name="event_edit_conflict_title">El evento fue cambiado por otra fuente</string>
<string name="event_edit_conflict_body">Mientras lo editabas, este evento fue cambiado — debido a la sincronización u otra aplicación ¿Que deberia le ocurrir a tus cambios?</string>
<string name="event_edit_conflict_overwrite">Guardar mis cambios</string>
<string name="event_edit_conflict_overwrite_hint">Sobreescribir el cambio externo en solo los aspectos que he cambiado</string>
<string name="event_edit_conflict_discard">Descartar todos mis cambios</string>
<string name="event_edit_conflict_discard_hint">El evento permanece tal y como esta ahora</string>
<string name="event_edit_gone_title">Evento eliminado</string>
<string name="event_edit_gone_body">Este evento fue eliminado durante el proceso, en otro dispositivo o aplicación por ejemplo. Tus cambios no pudieron ser guardados.</string>
<string name="event_edit_recurrence_none">No se repite</string>
<string name="event_edit_recurrence_custom">Personalizado</string>
<string name="event_edit_recurrence_every">Cada</string>
<string name="recurrence_unit_days">días</string>
<string name="recurrence_unit_weeks">semanas</string>
<string name="recurrence_unit_months">meses</string>
<string name="recurrence_unit_years">años</string>
<string name="event_edit_recurrence_ends">Finaliza</string>
<string name="event_edit_recurrence_end_never">Nunca</string>
<string name="event_edit_recurrence_end_until">En una fecha</string>
<string name="event_edit_recurrence_end_count">Después de cierto numero de veces</string>
<string name="event_edit_recurrence_times">veces</string>
<string name="event_edit_error_recurrence_ends_before_start">El evento se deja de repetir antes de que este comience</string>
<string name="event_availability_busy">Ocupado</string>
<string name="event_access_default">Por defecto</string>
<string name="event_access_public">Público</string>
<string name="event_detail_all_day">Todo el día</string>
<string name="event_detail_calendar">Calendario</string>
<string name="event_detail_calendar_unknown">Calendario desconocido</string>
<string name="event_detail_location">Ubicación</string>
<string name="event_detail_description">Descripción</string>
<string name="event_detail_attendees">Participantes</string>
<string name="event_detail_recurrence">Recurrencia</string>
<string name="agenda_today_action">Hoy</string>
<string name="agenda_header_today">Hoy</string>
<string name="search_back">Atras</string>
<string name="widget_new_event">Nuevo evento</string>
<string name="widget_prev_month">Mes anterior</string>
<string name="widget_next_month">Siguiente mes</string>
<string name="widget_today">Hoy</string>
<string name="filter_title">Calendarios</string>
<string name="settings_title">Ajustes</string>
<string name="settings_back">Atras</string>
<string name="agenda_range_day">Hoy</string>
<string name="agenda_range_this_week">Esta semana</string>
<string name="settings_section_calendars">Calendarios</string>
<string name="calendars_title">Calendarios</string>
<string name="calendars_color_label">Color</string>
<string name="import_close">Cerrar</string>
<string name="shortcut_new_event_short">Nuevo evento</string>
<string name="qs_tile_new_event_label">Nuevo evento</string>
<string name="event_detail_recurring">Repetir el evento</string>
<string name="recurrence_daily">Todos los días</string>
<string name="recurrence_weekly">Todas las semanas</string>
<string name="recurrence_monthly">Todos los meses</string>
<string name="recurrence_yearly">Todos los años</string>
<string name="recurrence_every_n_days">Cada %1$d días</string>
<string name="recurrence_every_n_weeks">Cada %1$d semanas</string>
<string name="recurrence_every_n_months">Cada %1$d meses</string>
<string name="recurrence_every_n_years">Cada %1$d años</string>
<string name="event_detail_not_found">Este evento ya no existe.</string>
<string name="event_attendee_accepted">Aceptado</string>
<string name="event_attendee_declined">Rechazado</string>
<string name="event_attendee_tentative">Tentativo</string>
<string name="event_attendee_needs_action">Sin respuesta</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Recordatorios</string>
<string name="event_detail_timezone">Zona horaria</string>
<string name="event_status_tentative">Tentativo</string>
<string name="event_status_cancelled">Cancelado</string>
<string name="event_availability_free">Disponible</string>
<string name="event_access_private">Privado</string>
<string name="event_access_confidential">Confidencial</string>
<string name="event_attendee_organizer">Organizador</string>
<string name="event_attendee_optional">Opcional</string>
<string name="agenda_range_custom">Personalizado…</string>
<string name="calendars_backup_header">Respaldo</string>
<string name="backup_channel_name">Respaldo</string>
<string name="event_detail_self_response">Tu respuesta: %1$s</string>
<string name="reminder_at_time">Al la hora del evento</string>
<string name="reminder_default">Recordatorio por defecto</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minuto antes</item>
<item quantity="many">%d minutos antes</item>
<item quantity="other">%d minutos antes</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d hora antes</item>
<item quantity="many">%d horas antes</item>
<item quantity="other">%d horas antes</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d día antes</item>
<item quantity="many">%d días antes</item>
<item quantity="other">%d días antes</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d semana antes</item>
<item quantity="many">%d semanas antes</item>
<item quantity="other">%d semanas antes</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d minuto</item>
<item quantity="many">%d minutos</item>
<item quantity="other">%d minutos</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d hora</item>
<item quantity="many">%d horas</item>
<item quantity="other">%d horas</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d días</item>
<item quantity="other">%d días</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">%d semana</item>
<item quantity="many">%d semanas</item>
<item quantity="other">%d semanas</item>
</plurals>
<string name="event_untitled">(Sin título)</string>
<string name="reminder_channel_name">Recordatorios del evento</string>
<string name="reminder_channel_description">Notificaciones a la hora de los recordatorios de tus eventos</string>
<string name="reminder_onboarding_title">Nunca te pierdas un evento</string>
<string name="reminder_onboarding_body">Android no muestra recordatorios de eventos por su cuenta — una app de calendario tiene que hacerlo. Deja que calendula cumpla ese trabajo.</string>
<string name="reminder_benefit_delivery_title">Recordatorios, enviados</string>
<string name="reminder_benefit_delivery_body">Cada recordatorio de tus eventos aparece como notificación, justo a tiempo.</string>
<string name="reminder_benefit_duplicates_title">¿Utilizas otra aplicación de calendario?</string>
<string name="reminder_benefit_duplicates_body">Si otra aplicación tambien muestra recordatorios, los veras dos veces — desactivalos en esta o la otra aplicación.</string>
<string name="reminder_benefit_reversible_title">Cambialo en cualquier momento</string>
<string name="reminder_benefit_reversible_body">El interruptor se encuentra en Ajustes, en la sección de Notificaciones.</string>
<string name="reminder_onboarding_enable_button">Activar recordatorios</string>
<string name="reminder_onboarding_skip_button">Mas tarde</string>
<string name="reminder_action_snooze">Posponer</string>
<string name="reminder_action_dismiss">Descartar</string>
<string name="view_month">Mes</string>
<string name="view_week">Semana</string>
<string name="view_day">Día</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Vista</string>
<string name="drawer_jump_to_date">Ir a la fecha</string>
<string name="agenda_header_tomorrow">Mañana</string>
<string name="agenda_empty_title">Ya estas al día</string>
<string name="search_action">Buscar</string>
<string name="search_hint">Buscar eventos</string>
<string name="search_clear">Limpiar</string>
<string name="search_idle_hint">Buscar eventos por titulo, ubicación o notas.</string>
<string name="search_empty">No evento coincide con “%1$s”.</string>
<string name="widget_agenda_title">Próximamente</string>
<string name="widget_agenda_label">Calendula agenda</string>
<string name="widget_month_label">Calendula mes</string>
<string name="widget_refresh">Actualizar</string>
<string name="widget_needs_permission">Abre Calendula para conceder acceso al calendario</string>
<string name="settings_section_appearance">Apariencia</string>
<string name="settings_theme">Tema</string>
<string name="settings_theme_system">Igual que el sistema</string>
<string name="settings_theme_light">Claro</string>
<string name="settings_theme_dark">Oscuro</string>
<string name="settings_default_view">Vista por defecto</string>
<string name="settings_dynamic_color">Color dinámico</string>
<string name="settings_dynamic_color_unavailable">Requiere android 12 o una versión posterior</string>
<string name="settings_week_start">La semana empieza en</string>
<string name="settings_week_start_auto">Automático</string>
<string name="settings_time_format">Formato de la hora</string>
<string name="settings_time_format_auto">Automático</string>
<string name="settings_time_format_12h">12-horas (2:00 PM)</string>
<string name="settings_time_format_24h">24-horas (14:00)</string>
<string name="settings_hour_lines">Lineas entre horas</string>
<string name="settings_hour_lines_summary">Muestra una linea que separa cada hora en la vista diaria y semanal</string>
<string name="settings_dim_completed">Oscurecer eventos completados</string>
<string name="settings_dim_completed_summary">Oscurece eventos que ya han terminado en vista semanal y mensual</string>
<string name="settings_past_events">Eventos pasados</string>
<string name="settings_past_events_show">Mostrar</string>
<string name="settings_past_events_dim">Oscurecer</string>
<string name="settings_past_events_hide">Ocultar</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Rango de agenda</string>
<string name="settings_agenda_range_hint">Cuanto tiempo abarca la agenda al mostrar eventos.</string>
<string name="settings_agenda_widget_range">Rango del widget de agenda</string>
<string name="settings_agenda_widget_range_hint">Cuanto tiempo abarca el widget de agenda en la pantalla principal al mostrar eventos.</string>
<string name="settings_agenda_range_bar">Barra de tiempo</string>
<string name="settings_agenda_range_bar_hint">Muestra una barra en la parte superior de la agenda con las fechas mostradas, con un botón para cambiar el rango durante la sesión</string>
<string name="agenda_range_this_month">Este mes</string>
<string name="agenda_range_week">Próximos 7 días</string>
<string name="agenda_range_month">Próximos 30 días</string>
<string name="agenda_range_custom_hint">Días</string>
<string name="agenda_range_override_hint">Solo por ahora — reinicia tu rango guardado cuando reabras calendula.</string>
<string name="agenda_range_showing_label">Mostrando todos los próximos eventos por</string>
<plurals name="agenda_range_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="settings_section_event_form">Nueva formulario de evento</string>
<string name="settings_form_fields_hint">Campos mostrados por defecto — todo lo demás se encuentra bajo \"mas campos\"</string>
<string name="settings_autofocus_title">Enfocar titulo en nuevos eventos</string>
<string name="settings_autofocus_title_hint">Cuando comienzas un nuevo evento, coloca el cursor en el titulo y abre el teclado inmediatamente.</string>
<string name="settings_color_unsupported">Permitir colores en calendarios no admitidos</string>
<string name="settings_color_unsupported_hint">Algunas calendarios (como algunos CalDAV) no publican un set de colores; un color customizado puede ser eliminado o sobreescrito por su servicio de sincronización. Eso es una limitacion de estos calendarios, no algo que calendula pueda solucionar.</string>
<string name="settings_section_notifications">Notificaciones</string>
<string name="settings_reminders">Recordatorios de eventos</string>
<string name="settings_reminders_hint">Los recordatorios aparecen dos veces? Otra aplicación de calendario tambien los esta generando — desactivarlo en una de las dos apps.</string>
<string name="settings_default_reminder">Recordatorio por defecto</string>
<string name="settings_default_reminder_allday">Eventos que duran todo el dia</string>
<string name="settings_allday_reminder_time">Hora del recordatorio de todo el día</string>
<string name="settings_allday_reminder_time_hint">Los recordatorios para eventos de todo el día se generaran a las %1$s</string>
<string name="reminder_none">Ninguno</string>
<string name="reminder_use_default">Usar recordatorio por defecto</string>
<string name="reminder_custom_amount">Cantidad</string>
<string name="reminder_custom_set">Guardar</string>
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
<string name="settings_calendar_reminder_inherits">Por defecto (%1$s)</string>
<string name="settings_reliable_delivery">Notificaciones confiables</string>
<string name="settings_reliable_delivery_hint">Android puede atrasar los recordatorios para guardar batería. Excluye calendula de la optimización de batería para que se puedan generar a tiempo.</string>
<string name="settings_reliable_delivery_exempt">Excluido de la optimización de batería — los recordatorios llegaran a tiempo.</string>
<string name="settings_snooze_duration">Duración al posponer</string>
<string name="settings_manage_calendars">Administrar calendarios</string>
<string name="settings_manage_calendars_hint">Crear calendarios locales; administrar calendarios sincronizados</string>
<string name="settings_section_language">Idioma</string>
<string name="settings_language">Idioma de la aplicación</string>
<string name="settings_language_auto">Igual que el sistema</string>
<string name="settings_translate">Ayuda a traducir</string>
<string name="settings_translate_hint">Añade o mejora un idioma en Weblate</string>
<string name="settings_appearance_subtitle">Tema, vista por defecto, inicio de semana</string>
<string name="settings_event_form_subtitle">Campos por defecto en nuevos eventos</string>
<string name="settings_notifications_subtitle">Recordatorios de eventos</string>
<string name="settings_license">Licencia</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">Por Jean-Luc Makiola</string>
<string name="settings_about_source">Fuente</string>
<string name="settings_about_support">Ayuda al desarrollo</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Icono de la aplicación calendula</string>
<string name="settings_report_problem">Reportar un problema</string>
<string name="settings_report_problem_hint">Enviar un reporte de error o abre el sistema de seguimiento de errores</string>
<string name="calendars_local_header">Tus calendarios</string>
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
<string name="calendars_add">Añadir calendario</string>
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
<string name="calendars_synced_header">Calendarios sincronizados</string>
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
<string name="calendars_account_menu_a11y">Mas opciones para %1$s</string>
<string name="calendars_enable_all">Habilitar todos</string>
<string name="calendars_disable_all">Desactivar todos</string>
<string name="calendars_add_account">Añadir cuenta</string>
<string name="calendars_new_title">Nuevo calendario</string>
<string name="calendars_edit_title">Editar calendario</string>
<string name="calendars_name_label">Nombre</string>
<string name="calendars_description_hint">Añadir descripción</string>
<string name="calendars_delete_confirm_title">¿Eliminar calendario?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" y todos sus eventos serán eliminados permanentemente de este dispositivo.</string>
<string name="calendars_write_error">No se pudieron guardar los cambios.</string>
<string name="calendars_backup_hint">Los calendarios locales no se sincronizan, exportalos como un archivo .ics para mantener una copia.</string>
<string name="calendars_backup_action">Exportar como archivo .ics</string>
<string name="calendars_auto_backup">Respaldo automático</string>
<string name="calendars_auto_backup_hint">Exporta tus calendarios a una carpeta como un archivo .ics de forma periodica.</string>
<string name="calendars_auto_backup_folder">Carpeta de respaldo</string>
<string name="calendars_auto_backup_folder_unset">Toca para seleccionar una carpeta</string>
<string name="calendars_auto_backup_interval">Intervalo</string>
<string name="calendars_auto_backup_every">Cada %1$s</string>
<string name="calendars_auto_backup_interval_min">Mínimo 30 minutos.</string>
<string name="calendars_auto_backup_status_never">Sin respaldos automáticos todavía</string>
<string name="calendars_auto_backup_status_ok">Ultimo respaldo: %1$s</string>
<string name="calendars_auto_backup_status_failed">Ultimo respaldo fallido: %1$s</string>
<string name="backup_channel_description">Advierte si los respaldos automáticos fallan repetidamente.</string>
<string name="backup_failed_title">El respaldo automático fallo</string>
<string name="backup_failed_text">Calendula no pudo generar el archivo de respaldo, comprueba la carpeta de respaldo en ajustes.</string>
<string name="calendars_backup_failed">No se pudo exportar el respaldo.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Se exporto %d evento.</item>
<item quantity="many">Se exportaron %d eventos.</item>
<item quantity="other">Se exportaron %d eventos.</item>
</plurals>
<string name="import_title">Importar eventos</string>
<string name="import_target_header">Añadir a calendario</string>
<string name="import_empty">No se encontraron eventos en este archivo.</string>
<string name="import_failed">No se pudo leer el archivo.</string>
<string name="import_no_calendar">No hay ningún calendario editable en el cual importar los eventos. Crea un calendario local primero.</string>
<string name="import_done_title">Importación completa</string>
<string name="import_warning_recurrence">Se omitieron algunas ocurrencias editadas de eventos recurrentes.</string>
<string name="import_warning_no_start">Se omitió un evento sin hora de inicio.</string>
<string name="import_warning_attendees">No se importaron las listas de invitados.</string>
<string name="import_warning_timezone">Se reemplazo una zona horaria desconocida con la de tu dispositivo.</string>
<plurals name="import_event_count">
<item quantity="one">%d evento en este archivo.</item>
<item quantity="many">%d eventos en este archivo.</item>
<item quantity="other">%d eventos en este archivo.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importar %d evento</item>
<item quantity="many">Importar %d eventos</item>
<item quantity="other">Importar %d eventos</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">%d evento importado.</item>
<item quantity="many">%d eventos importados.</item>
<item quantity="other">%d eventos importados.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Se omitió %d archivo ya presente en este calendario.</item>
<item quantity="many">Se omitieron %d archivos ya presentes en este calendario.</item>
<item quantity="other">Se omitieron %d archivos ya presentes en este calendario.</item>
</plurals>
<string name="shortcut_new_event_long">Crear un nuevo evento</string>
<string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string>
<string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string>
<string name="crash_dialog_title">Calendula ha fallado</string>
<string name="crash_dialog_message">Calendula se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
<string name="crash_dialog_report">Reportar</string>
<string name="crash_dialog_dismiss">No por ahora</string>
<string name="crash_report_issue_title">Reporte de error</string>
<string name="crash_report_clip_label">Reporte de error de Calendula</string>
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
<string name="crash_report_body_template">Gracias por reportar un error en Calendula. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%1$s\n</string>
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
<string name="event_attendee_resource">Recurso</string>
<string name="settings_section_about">Acerca de</string>
<string name="recurrence_with_count">%1$s, %2$d veces</string>
<string name="recurrence_with_until">%1$s hasta %2$s</string>
<string name="recurrence_on_days">%1$s los %2$s</string>
</resources>

View File

@@ -6,4 +6,416 @@
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Un calendario moderno.</string>
<string name="state_loading">Caricamento…</string>
<string name="state_retry">Riprova</string>
<string name="state_failure_unknown">Qualcosa è andato storto.</string>
<string name="state_failure_permission">È richiesto l\'accesso al calendario.</string>
<string name="state_failure_permission_action">Concedi l\'accesso</string>
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
<string name="permission_denied_title">Accesso al calendario negato</string>
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
<string name="permission_retry_button">Riprova</string>
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
<string name="month_prev">Mese precedente</string>
<string name="month_next">Mese prossimo</string>
<string name="month_today_action">Oggi</string>
<string name="month_more_actions">Altre azioni</string>
<string name="month_open_menu">Apri il menù</string>
<string name="month_action_settings">Impostazioni</string>
<string name="month_a11y_today_prefix">Oggi</string>
<string name="week_today_action">Questa settimana</string>
<string name="week_number_label">Wk</string>
<string name="day_today_action">Oggi</string>
<string name="event_detail_back">Indietro</string>
<string name="event_detail_edit">Modifica</string>
<string name="event_detail_delete">Elimina</string>
<string name="event_detail_share">Condividi</string>
<string name="event_share_chooser_title">Condividi evento</string>
<string name="event_share_failed">Impossibile condividere questo evento.</string>
<string name="event_delete_title">Eliminare l\'evento?</string>
<string name="event_delete_body">Questo evento viene rimosso dal tuo calendario e da tutti i dispositivi sincronizzati.</string>
<string name="event_delete_recurring_title">Elimina ricorrenza</string>
<string name="event_delete_option_occurrence">Solo questo evento</string>
<string name="event_delete_option_following">Questo e gli eventi successivi</string>
<string name="event_delete_option_series">Tutti gli eventi della serie</string>
<string name="event_edit_recurring_title">Modifica ricorrenza</string>
<string name="event_delete_failed">Impossibile eliminare l\'evento</string>
<string name="event_delete_write_denied">Calendula ha bisogno dei permessi di scrittura per eliminare gli eventi</string>
<string name="dialog_cancel">Annulla</string>
<string name="dialog_ok">Ok</string>
<string name="event_edit_new_title">Nuovo evento</string>
<string name="event_edit_close">Chiudi</string>
<string name="event_edit_save">Salva</string>
<string name="event_edit_title_hint">Aggiungi titolo</string>
<string name="event_edit_starts">Inizio</string>
<string name="event_edit_ends">Fine</string>
<string name="event_edit_error_end_before_start">Fine antecedente l\'inizio</string>
<string name="event_edit_error_no_calendar">Non sono disponibili calendari</string>
<string name="event_edit_save_failed">Impossibile salvare l\'evento</string>
<string name="event_edit_write_denied">Calendula ha bisogno dei permessi di scrittura per creare gli eventi</string>
<string name="event_edit_more_fields">Altri campi</string>
<string name="event_edit_add">Aggiungi</string>
<string name="event_edit_add_reminder">Aggiungi promemoria</string>
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
<string name="event_edit_attendees">Partecipanti</string>
<string name="event_edit_add_guest">Aggiungi partecipante</string>
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
<string name="event_edit_attendee_required">Obbligatorio</string>
<string name="event_edit_attendee_optional">Facoltativo</string>
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
<string name="event_edit_reminder_custom">Personalizza</string>
<string name="reminder_unit_minutes">minuti</string>
<string name="reminder_unit_hours">ore</string>
<string name="reminder_unit_days">giorni</string>
<string name="reminder_unit_weeks">settimane</string>
<string name="event_edit_availability">Disponibilità</string>
<string name="event_edit_visibility">Visibilità</string>
<string name="event_edit_color">Colore</string>
<string name="event_edit_color_default">Colore calendario</string>
<string name="event_edit_color_custom">Colore personalizzato</string>
<string name="event_edit_color_reset">Reset</string>
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
<string name="event_edit_color_unsupported_hint">I colori non sono ammessi per questo calendario. Puoi abilitare i colori personalizzati per questo tipo di calendario dalle Impostazioni.</string>
<string name="event_edit_color_sync_warning">Il colore per questo calendario potrà andare perso o essere sovrascritto alla prossima sincronizzazione.</string>
<string name="event_edit_conflict_title">Evento modificato altrove</string>
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
<string name="event_edit_gone_title">Evento eliminato</string>
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
<string name="event_edit_recurrence_none">Non si ripete</string>
<string name="event_edit_recurrence_custom">Personalizza</string>
<string name="event_edit_recurrence_every">La serie</string>
<string name="recurrence_unit_days">giorni</string>
<string name="recurrence_unit_weeks">settimane</string>
<string name="recurrence_unit_months">mesi</string>
<string name="recurrence_unit_years">anni</string>
<string name="event_edit_recurrence_ends">Fine</string>
<string name="event_edit_recurrence_end_never">Mai</string>
<string name="event_edit_recurrence_end_until">Fino alla data</string>
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
<string name="event_edit_recurrence_times">occorrenze</string>
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
<string name="event_availability_busy">Occupato</string>
<string name="event_access_default">Default</string>
<string name="event_access_public">Pubblico</string>
<string name="event_detail_all_day">Tutto il giorno</string>
<string name="event_detail_calendar">Calendario</string>
<string name="event_detail_calendar_unknown">Calendario sconosciuto</string>
<string name="event_detail_location">Luogo</string>
<string name="event_detail_description">Descrizione</string>
<string name="event_detail_attendees">Partecipanti</string>
<string name="recurrence_daily">Ogni giorni</string>
<string name="recurrence_weekly">Ogni settimana</string>
<string name="recurrence_monthly">Ogni mese</string>
<string name="recurrence_yearly">Ogni anno</string>
<string name="recurrence_every_n_days">Ogni %1$d giorni</string>
<string name="recurrence_every_n_weeks">Ogni %1$d settimane</string>
<string name="recurrence_every_n_months">Ogni %1$d mesi</string>
<string name="recurrence_every_n_years">Ogni %1$d anni</string>
<string name="event_detail_not_found">Questo evento è stato eliminato.</string>
<string name="event_attendee_accepted">Accettato</string>
<string name="event_attendee_declined">Rifiutato</string>
<string name="event_attendee_tentative">Provvisorio</string>
<string name="event_attendee_needs_action">Nessuna risposta</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Promemoria</string>
<string name="event_detail_timezone">Fuso orario</string>
<string name="event_status_tentative">Provvisorio</string>
<string name="event_status_cancelled">Annullato</string>
<string name="event_availability_free">Disponibile</string>
<string name="event_access_private">Privato</string>
<string name="event_access_confidential">Confidenziale</string>
<string name="event_attendee_organizer">Organizzatore</string>
<string name="event_attendee_optional">Facoltativo</string>
<string name="event_detail_self_response">La tua risposta: %1$s</string>
<string name="reminder_at_time">All\'ora dell\'evento</string>
<string name="reminder_default">Promemoria standard</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minuto prima</item>
<item quantity="many">%d minuti prima</item>
<item quantity="other">%d minuti prima</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d ora prima</item>
<item quantity="many">%d ore prima</item>
<item quantity="other">%d ore prima</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d giorno prima</item>
<item quantity="many">%d giorni prima</item>
<item quantity="other">%d giorni prima</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d settimana prima</item>
<item quantity="many">%d settimanw prima</item>
<item quantity="other">%d settimane prima</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d minuto</item>
<item quantity="many">%d minuti</item>
<item quantity="other">%d minuti</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d ora</item>
<item quantity="many">%d ore</item>
<item quantity="other">%d ore</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d giorno</item>
<item quantity="many">%d giorni</item>
<item quantity="other">%d giorni</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">%d settimana</item>
<item quantity="many">%d settimane</item>
<item quantity="other">%d settimane</item>
</plurals>
<string name="event_untitled">(nessun titolo)</string>
<string name="reminder_channel_name">Promemoria evento</string>
<string name="event_attendee_resource">Risorsa</string>
<string name="reminder_onboarding_title">Non perderti nessun evento</string>
<string name="reminder_onboarding_body">Android non mostra i promemoria per gli eventi autonomamente, serve un\'app. Lascia che sia Calendula a farlo.</string>
<string name="reminder_benefit_delivery_title">Promemoria, eccoli</string>
<string name="reminder_benefit_delivery_body">Tutti i promemoria dei tuoi eventi arrivano tramite notifica, proprio al momento giusto.</string>
<string name="reminder_benefit_duplicates_title">Stai usando un\'altra app calendario?</string>
<string name="reminder_benefit_duplicates_body">Se un\'altra app manda promemoria li vedrai due volte. Disattivali qui o nell\'altra app.</string>
<string name="reminder_benefit_reversible_title">Cambialo in ogni momento</string>
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
<string name="reminder_onboarding_skip_button">Non ora</string>
<string name="reminder_action_snooze">Posponi</string>
<string name="reminder_action_dismiss">Ignora</string>
<string name="view_month">Mese</string>
<string name="view_week">Settimana</string>
<string name="view_day">Giorno</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Vista</string>
<string name="drawer_jump_to_date">Vai alla data</string>
<string name="agenda_today_action">Oggi</string>
<string name="agenda_header_tomorrow">Domani</string>
<string name="agenda_empty_title">Hai fatto tutto</string>
<string name="search_action">Cerca</string>
<string name="search_hint">Cerca eventi</string>
<string name="search_back">Indietro</string>
<string name="search_clear">Pulisci</string>
<string name="search_idle_hint">Cerca gli eventi per titolo, luogo o note.</string>
<string name="search_empty">Nessun risultato per \"%1$s\".</string>
<string name="widget_agenda_title">Prossimamente</string>
<string name="widget_agenda_label">Agenda Calendula</string>
<string name="widget_month_label">Mese Calendula</string>
<string name="widget_refresh">Aggiorna</string>
<string name="widget_new_event">Nuovo evento</string>
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
<string name="widget_prev_month">Mese precedente</string>
<string name="filter_title">Calendari</string>
<string name="settings_section_appearance">Aspetto</string>
<string name="settings_theme">Tema</string>
<string name="settings_theme_system">Sistema</string>
<string name="settings_theme_light">Chiaro</string>
<string name="settings_theme_dark">Scuro</string>
<string name="settings_default_view">Vista di default</string>
<string name="settings_dynamic_color">Colori dinamici</string>
<string name="settings_dynamic_color_unavailable">Richiede Android 12 o superiore</string>
<string name="settings_week_start">La settimana comincia di</string>
<string name="settings_week_start_auto">Automatico</string>
<string name="settings_time_format">Formato ora</string>
<string name="settings_time_format_auto">Automatico</string>
<string name="settings_time_format_12h">12 ore</string>
<string name="settings_time_format_24h">24 ore</string>
<string name="settings_hour_lines">Righe per le ore</string>
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
<string name="settings_dim_completed">Attenua gli eventi completati</string>
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
<string name="settings_past_events">Eventi passati</string>
<string name="settings_past_events_show">Mostra</string>
<string name="settings_past_events_dim">Attenua</string>
<string name="settings_past_events_hide">Nascondi</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
<string name="agenda_range_day">Oggi</string>
<string name="agenda_range_this_month">Questo mese</string>
<string name="agenda_range_week">Prossimi 7 giorni</string>
<string name="agenda_range_month">Prossimi 30 giorni</string>
<string name="agenda_range_custom">Personalizza…</string>
<string name="agenda_range_custom_hint">Giorni</string>
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
<plurals name="agenda_range_days">
<item quantity="one">%d giorno</item>
<item quantity="many">%d giorni</item>
<item quantity="other">%d giorni</item>
</plurals>
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
<string name="settings_section_notifications">Notifiche</string>
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
<string name="reminder_none">Nessuno</string>
<string name="reminder_use_default">Usa il promemoria standard</string>
<string name="reminder_custom_amount">Valore</string>
<string name="reminder_custom_set">Imposta</string>
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<string name="settings_reliable_delivery">Notifiche affidabili</string>
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
<string name="settings_snooze_duration">Durata del posticipo</string>
<string name="settings_section_calendars">Calendari</string>
<string name="settings_manage_calendars">Gestisci calendari</string>
<string name="settings_manage_calendars_hint">Crea calendari locali; gestisci quelli sincronizzati</string>
<string name="settings_section_language">Lingua</string>
<string name="settings_language">Lingua dell\'app</string>
<string name="settings_language_auto">Lingua di sistema</string>
<string name="settings_translate">Aiuta a tradurre</string>
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
<string name="settings_section_about">A proposito</string>
<string name="settings_license">Licenza</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">da Jean-Luc Makiola</string>
<string name="settings_about_source">Sorgente</string>
<string name="settings_about_support">Sostieni lo sviluppo</string>
<string name="settings_about_version">Versione %1$s</string>
<string name="settings_about_logo_desc">Icona dell\'app</string>
<string name="settings_report_problem">Segnala un problema</string>
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
<string name="calendars_title">Calendari</string>
<string name="calendars_local_header">I tuoi calendari</string>
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
<string name="calendars_add">Aggiungi calendario</string>
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
<string name="calendars_synced_header">Calendari sincronizzati</string>
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
<string name="calendars_manage_in_app">Gestisci in app</string>
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
<string name="calendars_enable_all">Attiva tutti</string>
<string name="calendars_disable_all">Disattiva tutti</string>
<string name="calendars_add_account">Aggiungi account</string>
<string name="calendars_new_title">Nuovo calendario</string>
<string name="calendars_edit_title">Modifica calendario</string>
<string name="calendars_name_label">Nome</string>
<string name="calendars_color_label">Colore</string>
<string name="calendars_description_hint">Aggiungi descrizione</string>
<string name="calendars_delete_confirm_title">Eliminare calendario?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" e tutti i suoi eventi saranno definitivamente rimossi da questo dispositivo.</string>
<string name="calendars_write_error">Impossibile salvare le modifiche.</string>
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">I calendari locali non vengono sincronizzati da nessuna parte, esportali in un file .ics per conservarne una copia.</string>
<string name="calendars_backup_action">Esporta come file .ics</string>
<string name="calendars_auto_backup">Backup automatico</string>
<string name="calendars_auto_backup_hint">Esporta periodicamente in una cartella i tuoi calendari in formato .ics.</string>
<string name="calendars_auto_backup_folder">Cartella di backup</string>
<string name="calendars_auto_backup_folder_unset">Scegli una cartella</string>
<string name="calendars_auto_backup_interval">Intervallo</string>
<string name="calendars_auto_backup_every">Ogni %1$s</string>
<string name="calendars_auto_backup_interval_min">Minimo 30 minuti.</string>
<string name="calendars_auto_backup_status_never">Backup automatico non attivo</string>
<string name="calendars_auto_backup_status_ok">Ultimo backup: %1$s</string>
<string name="calendars_auto_backup_status_failed">Ultimo backup fallito: %1$s</string>
<string name="backup_channel_name">Backup</string>
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
<string name="backup_failed_title">Backup automatico fallito</string>
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Esportato %d evento.</item>
<item quantity="many">Esportati %d eventi.</item>
<item quantity="other">Esportati %d eventi.</item>
</plurals>
<string name="import_title">Importa eventi</string>
<string name="import_target_header">Aggiungi al calendario</string>
<string name="import_empty">Non ci sono eventi in questo file.</string>
<string name="import_failed">Impossibile leggere il file.</string>
<string name="import_no_calendar">Non ci sono calendari su cui importare. Crea prima un calendario locale.</string>
<string name="import_done_title">Import completato</string>
<string name="import_close">Chiudi</string>
<string name="import_warning_recurrence">Alcune occorrenze degli eventi ricorrenti, che erano state modificate, sono state saltate.</string>
<string name="import_warning_no_start">Un evento senza orario di inizio è stato saltato.</string>
<string name="import_warning_attendees">Gli elenchi dei partecipanti non sono stati importati.</string>
<string name="import_warning_timezone">Un fuso orario sconosciuto è tornato sul tuo dispositivo.</string>
<plurals name="import_event_count">
<item quantity="one">%d evento in questo file.</item>
<item quantity="many">%d eventi in questo file.</item>
<item quantity="other">%d eventi in questo file.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importa %d evento</item>
<item quantity="many">Importa %d eventi</item>
<item quantity="other">Importa %d eventi</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Importato %d evento.</item>
<item quantity="many">Importati %d eventi.</item>
<item quantity="other">Importati %d eventi.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Saltato %d evento già presente nel calendario.</item>
<item quantity="many">SaltatI %d eventi già presentI nel calendario.</item>
<item quantity="other">SaltatI %d eventi già presentI nel calendario.</item>
</plurals>
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
<string name="qs_tile_new_event_label">Nuovo evento</string>
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
<string name="crash_dialog_title">Calendula è crashato</string>
<string name="crash_dialog_message">Calendula si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari:solo le informazioni tecniche qui sotto.</string>
<string name="crash_dialog_report">Report</string>
<string name="crash_dialog_dismiss">Non ora</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="crash_report_clip_label">Calendula crash report</string>
<string name="crash_report_copied">Report copiato negli Appunti</string>
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è sugli Appunti.</string>
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%1$s\n</string>
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
<string name="event_detail_recurrence">Ricorrenza</string>
<string name="event_detail_recurring">Evento ricorrente</string>
<string name="recurrence_on_days">%1$s da %2$s</string>
<string name="recurrence_with_until">%1$s fino a %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d volte</string>
<string name="reminder_channel_description">Notifiche al momento del promemoria dei tuoi eventi</string>
<string name="agenda_header_today">Oggi</string>
<string name="widget_next_month">Mese prossimo</string>
<string name="widget_today">Oggi</string>
<string name="settings_title">Impostazioni</string>
<string name="settings_back">Indietro</string>
<string name="agenda_range_this_week">Questa settimana</string>
<string name="settings_reminders">Promemoria evento</string>
<string name="settings_default_reminder">Promemoria standard</string>
<string name="settings_notifications_subtitle">Promemoria evento</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
</resources>

View File

@@ -14,7 +14,7 @@
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
<string name="permission_request_button">Grant calendar access</string>
<string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
@@ -68,6 +68,8 @@
<string name="event_edit_close">Close</string>
<string name="event_edit_save">Save</string>
<string name="event_edit_title_hint">Add title</string>
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
<string name="event_edit_starts">Starts</string>
<string name="event_edit_ends">Ends</string>
<string name="event_edit_error_end_before_start">Ends before it starts</string>
@@ -360,6 +362,35 @@
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>
<!-- Contact special dates (issue #15) -->
<string name="settings_section_special_dates">Contact special dates</string>
<string name="settings_special_dates_enable">Show contact dates</string>
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
<string name="settings_special_dates_type_birthday">Birthdays</string>
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
<string name="settings_special_dates_type_custom">Other dates</string>
<string name="settings_special_dates_template">Title format</string>
<string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
<string name="settings_special_dates_reminders">Reminders</string>
<string name="settings_special_dates_show_year">Show year</string>
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
<string name="settings_special_dates_sync_now">Sync now</string>
<string name="settings_special_dates_never_synced">Not synced yet</string>
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
<string name="settings_special_dates_paused_title">Paused</string>
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
<string name="settings_special_dates_grant">Grant access</string>
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
<!-- %1$s is the date type, e.g. "Birthdays". -->
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
<string name="settings_special_dates_disable_confirm">Turn off</string>
<string name="dialog_save">Save</string>
<string name="settings_section_about">About</string>
<string name="settings_license">License</string>
<string name="settings_license_value">MIT</string>
@@ -478,4 +509,13 @@
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
at the upcoming date (dropped when the year is unknown or Show age is off). -->
<string name="special_dates_calendar_birthday">Birthdays</string>
<string name="special_dates_calendar_anniversary">Anniversaries</string>
<string name="special_dates_calendar_custom">Special dates</string>
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -10,4 +10,6 @@
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="it" />
</locale-config>

View File

@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
}
@Test
fun `next yearly occurrence wraps to next year once this year's date has passed`() {
val today = LocalDate.of(2026, 8, 1)
assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14))
assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25))
}
@Test
fun `next yearly occurrence returns today when the date is today`() {
val today = LocalDate.of(2026, 3, 10)
assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today)
}
@Test
fun `next yearly occurrence skips non-leap years for a Feb-29 date`() {
assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1)))
.isEqualTo(LocalDate.of(2028, 2, 29))
}
@Test
fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() {
// A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then,
// UTC+1). Sampling the offset there skews a modern CEST occurrence by an
// hour; sampling at the upcoming occurrence fires at the intended time.
val anchor1972 = LocalDate.of(1972, 7, 15)
val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST
val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm)
val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm)
val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence)
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
val fireFromNext = actualFire(rawFromNext, nextOccurrence)
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
}
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An

View File

@@ -1,12 +1,15 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import java.time.ZoneId
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteCalendar(id: Long) {
writeError?.let { throw it }
deletedCalendarIds += id
managedCalendarRows.removeAll { it.id == id }
managedEvents.remove(id)
}
/** All-day reminder fire-time minute-of-day passed into the last write. */
@@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
}
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
var nextManagedEventId: Long = 1_000L
private var managedCalendarSeq: Long = 700L
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
managedCalendarRows += ManagedCalendarRow(id, type)
managedEvents.getOrPut(id) { mutableListOf() }
}
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
writeError?.let { throw it }
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
val id = managedCalendarSeq++
managedCalendarRows += ManagedCalendarRow(id, type)
managedEvents.getOrPut(id) { mutableListOf() }
return id
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
managedEvents[calendarId]?.toList() ?: emptyList()
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
writeError?.let { throw it }
insertedManagedEvents += form to uid
allDayReminderTimes += allDayReminderTimeMinutes
val id = nextManagedEventId++
val times = form.toWriteTimes(ZoneId.systemDefault())
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
return id
}
/** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
val appliedManagedReminders = mutableListOf<Triple<Long, Int, List<Int>>>()
override fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
writeError?.let { throw it }
appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
writeError?.let { throw it }
if (values.isEmpty()) return
updatedManagedFields += eventId to values
managedEvents.values.forEach { rows ->
val idx = rows.indexOfFirst { it.eventId == eventId }
if (idx >= 0) {
var row = rows[idx]
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
if (values.containsKey(CalendarContract.Events.RRULE)) {
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
}
rows[idx] = row
}
}
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {

View File

@@ -0,0 +1,226 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.provider.CalendarContract
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SpecialDatesSyncEngineTest {
private class FakeContacts(
var permission: Boolean = true,
var dates: List<ContactSpecialDate> = emptyList(),
) : ContactSpecialDatesDataSource {
override fun hasPermission() = permission
override fun readSpecialDates() = dates
}
private class FakeSpec : SpecialDatesCalendarSpec {
override fun displayName(type: SpecialDateType) = type.name
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
SpecialDateType.Birthday -> "{name}'s birthday ({year})"
SpecialDateType.Anniversary -> "{name}'s anniversary"
SpecialDateType.Custom -> "{name}"
}
}
private fun prefs(tempDir: Path): SettingsPrefs {
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
)
return SettingsPrefs(store)
}
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
private suspend fun engineWith(
tempDir: Path,
contacts: FakeContacts,
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
enabled: Boolean = true,
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
val settings = prefs(tempDir)
if (enabled) settings.setSpecialDatesEnabled(true)
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
}
@Test
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(
dates = listOf(
birthday("a", "Jane", 5, 14, 1990),
birthday("b", "Bob", 12, 25, null),
),
)
val (engine, settings) = engineWith(tempDir, contacts, calendars)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Success)
// All three type calendars exist; the birthday one holds both contacts.
assertThat(calendars.createdManagedCalendars.map { it.type })
.containsExactlyElementsIn(SpecialDateType.entries)
assertThat(calendars.insertedManagedEvents).hasSize(2)
assertThat(calendars.insertedManagedEvents.map { it.second })
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
// Year-less contact drops the year; year-known keeps it.
val titles = calendars.insertedManagedEvents.map { it.first.title }
assertThat(titles).contains("Jane's birthday (1990)")
assertThat(titles).contains("Bob's birthday")
// Calendar ids were persisted for the editor / next sync.
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
}
@Test
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
val insertsAfterFirst = calendars.insertedManagedEvents.size
val calendarsAfterFirst = calendars.createdManagedCalendars.size
engine.sync()
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
assertThat(calendars.updatedManagedFields).isEmpty()
assertThat(calendars.deletedEventIds).isEmpty()
}
@Test
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
engine.sync()
assertThat(calendars.updatedManagedFields).hasSize(1)
val (_, columns) = calendars.updatedManagedFields.single()
// Only managed columns are ever written — never reminders/location.
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)")
}
@Test
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
contacts.dates = emptyList()
engine.sync()
assertThat(calendars.deletedEventIds).hasSize(1)
}
@Test
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync()
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
engine.sync()
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
}
@Test
fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, settings) = engineWith(tempDir, contacts, calendars)
engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
// Persisted as the per-calendar all-day override (so new events match)...
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
.isEqualTo(listOf(0, 1440))
// ...and applied to the existing events in that calendar.
assertThat(calendars.appliedManagedReminders).contains(
Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)),
)
}
@Test
fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
}
// --- pure diff ---
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventDesired(uid, title, dt, rrule)
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventRow(id, uid, title, dt, rrule)
@Test
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
val diff = diffManagedEvents(
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
)
assertThat(diff.insertUids).containsExactly("new")
assertThat(diff.deleteEventIds).containsExactly(2L)
assertThat(diff.updates).isEmpty()
}
@Test
fun `diff emits only the changed managed columns`() {
val diff = diffManagedEvents(
desired = listOf(desired("a", "Janet", 150)),
existing = listOf(row(9, "a", "Jane", 100)),
)
val update = diff.updates.single()
assertThat(update.eventId).isEqualTo(9L)
assertThat(update.columns.keys)
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
}
}

View File

@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -465,4 +466,59 @@ class SettingsPrefsTest {
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
}
@Test
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesEnabled.first()).isFalse()
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
assertThat(prefs.managedCalendarIds.first()).isEmpty()
assertThat(prefs.specialDatesShowYear.first()).isTrue()
}
@Test
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
assertThat(prefs.specialDatesTypes.first())
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
}
@Test
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
}
@Test
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
.isEqualTo("{name}'s birthday")
}
@Test
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
prefs.recordSpecialDatesRun(2000L, null)
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
}
}

View File

@@ -0,0 +1,115 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class InsertEventFormTest {
private val zone = TimeZone.UTC
// A fixed "now" at 2026-01-15T10:20:00Z; the next full hour is 11:00.
private val now = LocalDateTime(2026, 1, 15, 10, 20).toInstant(zone)
private fun build(
beginMillis: Long? = null,
endMillis: Long? = null,
isAllDay: Boolean = false,
title: String? = null,
description: String? = null,
location: String? = null,
rrule: String? = null,
): EventForm = buildInsertEventForm(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = isAllDay,
title = title,
description = description,
location = location,
rrule = rrule,
zone = zone,
now = now,
)
private fun millis(dateTime: LocalDateTime): Long = dateTime.toInstant(zone).toEpochMilliseconds()
@Test
fun `timed event maps all provided fields`() {
val start = LocalDateTime(2026, 3, 1, 14, 30)
val end = LocalDateTime(2026, 3, 1, 15, 45)
val form = build(
beginMillis = millis(start),
endMillis = millis(end),
title = "Standup",
description = "Daily",
location = "Room 1",
rrule = "FREQ=DAILY",
)
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
assertThat(form.isAllDay).isFalse()
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(end)
assertThat(form.title).isEqualTo("Standup")
assertThat(form.description).isEqualTo("Daily")
assertThat(form.location).isEqualTo("Room 1")
assertThat(form.rrule).isEqualTo("FREQ=DAILY")
}
@Test
fun `missing times default to the next full hour and a one-hour duration`() {
val form = build(title = "Quick note")
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
assertThat(form.title).isEqualTo("Quick note")
}
@Test
fun `timed event with only a start gets a one-hour end`() {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val form = build(beginMillis = millis(start))
assertThat(form.start).isEqualTo(start)
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
fun `an end before the start is ignored and falls back to plus one hour`() {
val start = LocalDateTime(2026, 5, 2, 9, 0)
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
}
@Test
fun `all-day event uses UTC dates and decrements the exclusive end`() {
val startMillis = LocalDate(2026, 1, 1).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
val endExclusive = LocalDate(2026, 1, 3).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
val form = build(beginMillis = startMillis, endMillis = endExclusive, isAllDay = true, title = "Trip")
assertThat(form.isAllDay).isTrue()
assertThat(form.start.date).isEqualTo(LocalDate(2026, 1, 1))
assertThat(form.end.date).isEqualTo(LocalDate(2026, 1, 2))
// Placeholder wall-clock times survive a switch back to a timed event.
assertThat(form.start.time).isEqualTo(LocalTime(9, 0))
assertThat(form.end.time).isEqualTo(LocalTime(10, 0))
}
@Test
fun `all-day event without an end is a single day`() {
val startMillis = LocalDate(2026, 6, 10).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
val form = build(beginMillis = startMillis, isAllDay = true)
assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 10))
assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 10))
}
@Test
fun `a leading RRULE prefix is stripped and a blank rule becomes null`() {
assertThat(build(rrule = "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY")
assertThat(build(rrule = " ").rrule).isNull()
assertThat(build(rrule = null).rrule).isNull()
}
}

View File

@@ -0,0 +1,64 @@
package de.jeanlucmakiola.calendula.domain.contacts
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ContactSpecialDateTest {
@Test
fun `parses a full yyyy-MM-dd date with year`() {
val parts = parseContactEventDate("1990-05-14")
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
}
@Test
fun `parses a year-less --MM-dd date`() {
val parts = parseContactEventDate("--05-14")
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
}
@Test
fun `parses a compact yyyyMMdd date`() {
val parts = parseContactEventDate("19900514")
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
}
@Test
fun `parses a year-less MM-dd date without the double-dash prefix`() {
val parts = parseContactEventDate("12-25")
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
}
@Test
fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
val parts = parseContactEventDate("--02-29")
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
}
@Test
fun `keeps Feb 29 in a real leap year`() {
assertThat(parseContactEventDate("2000-02-29"))
.isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
}
@Test
fun `rejects Feb 29 in a non-leap year`() {
assertThat(parseContactEventDate("1999-02-29")).isNull()
}
@Test
fun `rejects an impossible month or day`() {
assertThat(parseContactEventDate("2020-13-01")).isNull()
assertThat(parseContactEventDate("2020-06-31")).isNull()
assertThat(parseContactEventDate("--00-10")).isNull()
}
@Test
fun `rejects blank, null and garbage input`() {
assertThat(parseContactEventDate(null)).isNull()
assertThat(parseContactEventDate("")).isNull()
assertThat(parseContactEventDate(" ")).isNull()
assertThat(parseContactEventDate("not-a-date")).isNull()
assertThat(parseContactEventDate("2020")).isNull()
}
}

View File

@@ -0,0 +1,78 @@
package de.jeanlucmakiola.calendula.domain.contacts
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class SpecialDateFormattingTest {
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
lookupKey = "k",
displayName = "Jane",
type = SpecialDateType.Birthday,
month = month,
day = day,
year = year,
)
@Test
fun `uid is deterministic and namespaced by type`() {
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
.isEqualTo("contact-birthday:abc@calendula")
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
.isEqualTo("contact-anniversary:abc@calendula")
}
private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate(
lookupKey = "k",
displayName = "Jane",
type = SpecialDateType.Custom,
month = month,
day = day,
year = null,
label = label,
)
@Test
fun `birthday and anniversary uids ignore the discriminator`() {
assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula")
}
@Test
fun `two custom dates on one contact get distinct uids by label`() {
assertThat(custom("Wedding", 6, 10).managedUid())
.isEqualTo("contact-custom:k:wedding@calendula")
assertThat(custom("Graduation", 9, 1).managedUid())
.isNotEqualTo(custom("Wedding", 6, 10).managedUid())
}
@Test
fun `label-less custom dates fall back to month-day so distinct dates survive`() {
assertThat(custom(null, 6, 10).managedUid())
.isEqualTo("contact-custom:k:06-10@calendula")
assertThat(custom(null, 9, 1).managedUid())
.isNotEqualTo(custom(null, 6, 10).managedUid())
}
@Test
fun `anchor date uses the known year`() {
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
}
@Test
fun `anchor date falls back to a leap year when year-less`() {
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
}
@Test
fun `title substitutes name and year`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
.isEqualTo("Jane's birthday (1990)")
}
@Test
fun `title drops an empty year parenthesis and tidies spacing`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
.isEqualTo("Jane's birthday")
}
}

View File

@@ -0,0 +1,248 @@
# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
Date: 2026-06-30 (implemented 2026-07-01)
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
Implementation notes / deviations from this design:
- **Event identity key is a deterministic `Events.UID_2445`**
(`contact-<type>:<lookupKey>@calendula`), not `SYNC_DATA1`. On a LOCAL calendar
the provider drops `_sync` columns unless written through a sync-adapter URI;
`UID_2445` is written/read through the normal event URIs the app already uses
(and doubles as a stable iCal UID). The managed **calendar** still carries a
`CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
sync-adapter URI).
- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
age is impossible without per-year exception rows (rejected as too heavy). The
template instead offers `{year}` — the birth year (or an anniversary's start
year) — which is static and correct on every occurrence. A "Show year" toggle
gates it; it's in the default templates ("{name}'s birthday ({year})").
- New managed calendars seed a per-calendar all-day reminder default of
on-the-day + one week before, so birthdays get lead time out of the box.
- Title template is user-editable per type from day one (`{name}`, `{year}`).
This document captures the full design for surfacing contact birthdays —
and, by extension, anniversaries and custom dates — as auto-updating local
calendars in Calendula. It also specifies #14 (per-calendar *multiple*
default reminders), which is a hard prerequisite and ships first.
The two pieces are deliberately split across **two branches / two
shipments**:
- `feat/per-calendar-multi-reminders`#14, ships first.
- `feat/contact-special-dates`#15, builds on #14.
They *may* release together depending on timing, but #14 is independently
useful and is the foundation the special-dates calendars lean on.
---
## Why this is worth doing carefully
Calendula's identity is privacy: **no INTERNET permission**, and historically
**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
never holds the contacts permission (`EventEditScreen.kt:446-460`).
This feature **declares `READ_CONTACTS`** for the first time. That is a
deliberate, owner-approved shift, mitigated by:
- The permission is **opt-in and feature-gated** — requested only when the user
enables the feature, never at startup.
- Everything stays **offline**. Contacts are read locally and mirrored into a
local calendar; nothing leaves the device.
- The mirror is **one-way** (contacts → calendar). Calendula never writes to
Contacts.
Store listing / About copy will need to explain the optional permission.
---
## Prerequisite — #14: per-calendar *multiple* default reminders
### Today
- Events already support **multiple reminders** — `EventForm.reminders:
List<Int>` (`EventForm.kt:24`), each written as a separate
`CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each
firing independently. The runtime/notification path is done.
- The **defaults** layer is single-valued, though:
- Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single
`Int` (`SettingsPrefs.kt:287,302`).
- Per-calendar override: `perCalendarReminderOverride: Map<Long, Int?>` and
the all-day variant (`SettingsPrefs.kt:350-381`), resolved by
`resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`).
- UI: expandable per-calendar override section
(`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`).
### The change
Widen the **defaults**, not the runtime, from one reminder to a list:
1. Resolution model `Int` → `List<Int>` in global defaults + per-calendar
override + `resolveDefaultReminder()`. The DataStore string format already
parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
comma-separated list. Keep the `Inherit / None / Minutes` override states
(`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
a list.
2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
UI rather than inventing new interaction.
3. New events seed `EventForm.reminders` from the **resolved list** instead of
a single value.
No new permissions, no provider changes, no scheduling changes. Type widening
plus one picker upgrade.
### Why it gates #15
Each special-dates calendar is a normal local calendar, so it inherits the
**per-calendar reminder default** for free. A birthdays calendar is useless
without lead time (the reporter literally asked for "a week before *and* on the
day") — that requires *multiple* defaults, i.e. #14.
---
## #15: Contact special-dates calendars
### Core model: one-way mirror, one local calendar per type
Contacts → **separate local calendars per type**:
- "Birthdays"
- "Anniversaries"
- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
Separate-per-type is the key architectural decision: each is a normal local
calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
**existing per-calendar infra applies for free**:
- **Color** → existing per-calendar color.
- **Show/hide** → existing calendar visibility.
- **Default reminders** → per-calendar override from #14.
This shrinks the new surface dramatically: anniversaries and custom dates are
near copy-paste of birthdays (same engine, different `ContactsContract` data
kind, one extra local calendar each), and the settings sub-page does **not**
need to reinvent color/visibility/reminders.
### Events: field-level managed, not read-only
Calendula manages a few fields; the user owns the rest.
- **Managed** (overwritten on every sync, disabled in the editor for these
events): title, start date, recurrence (`FREQ=YEARLY`), existence.
- **User-owned** (seeded once on insert, never touched again by sync):
location, reminders, notes, busy/free.
The rule that makes this safe: sync performs a **targeted column update**
(managed columns only), **never delete-and-reinsert**. Reminders live in
separate rows, so once seeded they are simply never re-touched. A user can move
a birthday reminder to "2 weeks before" or add a location and neither is
clobbered.
In the editor, when an event belongs to a managed calendar: disable the
title/date/recurrence fields; allow the rest.
### Reconciliation identity
Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
- contact added → insert
- birthday/date changed → targeted update of managed columns
- contact or date removed → delete the event
Getting this wrong = duplicate birthdays on every sync, so the identity key is
non-negotiable.
### Data source
`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
`Event.TYPE`:
- `TYPE_BIRTHDAY` → Birthdays calendar
- `TYPE_ANNIVERSARY` → Anniversaries calendar
- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
### Dates: year-less + age
Many contacts store `--MM-DD` (no year). Handle both:
- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
age ("Jane turns 30") when the show-age option is on.
- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
(`Recurrence.kt:32-174`).
### Auto-update cadence
Birthdays change rarely, so nothing live/expensive:
- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
(catches new/edited/deleted contacts even if the app isn't opened).
- **No `ContentObserver`** — needs the process alive and buys almost nothing
for once-a-year events.
### Permission + lifecycle
- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
grant, create the enabled calendars and run the first sync.
- **Disable:** delete the managed calendars + their events (with a confirm).
- **Permission revoked in system settings:** detect on next sync; surface that
the feature is stalled rather than failing silently.
### Configurable settings sub-page
Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
color/visibility/reminders already live in normal calendar settings, the
sub-page only carries the genuinely new knobs:
- Master enable toggle.
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
- Show-age toggle (only meaningful when the year is known).
- (Possibly) sync-cadence / "sync now".
- Pointer to the normal calendar settings for color, visibility, reminders.
The owner wants this broadly configurable — keep the sub-page the home for
display/behaviour options, and lean on existing per-calendar settings for the
rest.
---
## Open questions / deferred
- Title template: free-form vs a small set of presets. Start with one good
localized default; revisit configurability.
- Whether "Custom dates" is one bucket or split further by label. Start: one
bucket.
- Contact scope: all contacts vs per-account/group filtering. Start: all;
filtering is a later refinement.
## Task outline
### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
- [x] Widen global + per-calendar reminder defaults `Int` → `List<Int>`
(`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
- [x] `resolveDefaultReminder()` returns a list.
- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
- [x] Seed new events' `EventForm.reminders` from the resolved list.
- [x] Migration / parse compatibility for existing single-value stored prefs.
- [x] Tests: resolution, parse round-trip, all-day variant.
### #15 — contact special-dates (`feat/contact-special-dates`)
- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
- [x] Per-type local calendars (create on enable, delete on disable+confirm).
- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
user-owned fields on insert only.
- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct).
- [x] Auto-update: on enable, on foreground, daily WorkManager.
- [x] Editor: disable managed fields for managed-calendar events.
- [x] Revoked-permission detection + stalled-state surface.
- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
- [x] Translatable strings (titles, sub-page).
- [x] About / store copy for the optional `READ_CONTACTS` permission.

View File

@@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
Termin.
Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage
deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte
werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert —
Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll
kontrollierst.
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.

View File

@@ -0,0 +1,70 @@
### Added
- Calendula now speaks Spanish and Italian. Both arrived as community
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
— a **huge thank you** to
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
translation and to
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
one! Pick your language under Settings → Language or in Android's per-app
language settings. Strings added in this very release may still show in
English until the translations catch up. Want Calendula in your language?
Translating happens entirely in the browser — every contribution is welcome.
- See your contacts' birthdays and anniversaries in your calendar. A new,
**optional** feature (Settings → Contact special dates) mirrors your contacts'
birthdays, anniversaries and other dates into local "Birthdays",
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
change. Each is a normal local calendar, so you set its colour, visibility and
reminders the usual way — new birthdays even start with a reminder a week before
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
This is the first feature to use the contacts permission: it is requested only
when you turn the feature on, everything stays on your device (Calendula has no
internet access), and your contacts are only ever read, never changed. Thanks
to @moonj for the suggestion ([#15]).
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
- Widget headers now open the app. Tapping the month/year title on the month
widget opens the app on the month view, and tapping the "Upcoming" title on
the agenda widget opens it on your default view — so there's a one-tap way
back into the app that lands where you'd expect, instead of only through a day
or event. On the month widget, tapping anywhere on a day — not just the small
date number — now opens that day, and the "today" button snaps the grid back
to the current month in place. Thanks to @rgz46vic and @ptab for the
suggestions ([#18], [#20]).
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
and **Body font** pickers: keep the system default, choose a bundled face
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
face), or load any `.ttf`/`.otf` file from your device, independently per
role. Font size and colour stay with Android's accessibility scaling and the
app theme, as discussed on the issue. Thanks to @abrossimow for the
suggestion ([#19]).
- Choose what the view-switch button cycles through. Settings → Appearance now
lets you pick which views the top-right quick-switch button rotates between
and drag them into your preferred order; views you switch off stay reachable
from the navigation menu, which can be reordered the same way. Thanks to
@abrossimow for the suggestion ([#24]).
### Fixed
- Month widget arrows and "today" button work again. On release builds the
prev/next-month arrows and the jump-to-today control on the month widget did
nothing when tapped — code shrinking had stripped the tap handlers behind
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
- Disabled calendars no longer notify. Reminders for events in a calendar you
have disabled (Settings → Calendars) are now suppressed instead of still
popping up — matching how a disabled calendar's events already stay hidden
everywhere else in the app ([#17]).
- Editing a single occurrence of a recurring event works again. Choosing **Only
this event** and saving a change to one event in a repeating series silently
did nothing — the change was rejected and the edit form simply reappeared with
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
- Every calendar can be picked when creating an event. The event editor's
calendar picker was a fixed-height dialog, so with many calendars anything
past the first nine or so was simply unreachable. It is now a full-screen,
scrollable list grouped by account, with each calendar's colour shown.
Thanks to @dschuermann for the report ([#29]).

View File

@@ -8,6 +8,11 @@ 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.
Optionally, show your contacts' birthdays, anniversaries and other special
dates in your calendar. You turn this on yourself; contacts are read only on
your device, are never uploaded, and are never changed — Calendula only
mirrors the dates into local calendars you fully control.
The differentiator is the design: real Material 3 Expressive throughout,
with dynamic color, expressive motion, and expressive shapes.