Compare commits

..

60 Commits

Author SHA1 Message Date
6519cbca79 release: cut 2.15.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 11m17s
Move the Unreleased changelog under [2.15.0] — 2026-07-15, bump
versionName/versionCode to 2.15.0/21500, and sync the F-Droid
per-version changelog.

Milestone 2.15.0 (all integrated): #21 #35 #36 #39 #40 #46 #52.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:34:52 +02:00
50510a23da feat(colors): optional raw calendar colours + readable title contrast
Add a "Soften calendar colours" setting (Settings → Design, default on).
Turning it off paints calendar and event colours raw, exactly as the sync
source (DAVx5/CalDAV) publishes them, instead of the theme-fitting pastels
(#36).

Event/calendar colours now flow through shared eventFill()/eventInk()
helpers gated by a LocalSoftenColors composition local (widgets read the
pref directly). Event titles pick black or white text by the fill's WCAG
relative luminance, so a dark colour stays legible whether softened or raw
(#21) — previously always near-black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:26:34 +02:00
2279738371 docs(changelog): note the new French and Polish translations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:42:19 +02:00
3d59962112 i18n: offer French and Polish in the language picker
Both landed as community translations through Weblate (French ~33%, Polish
~57%), above the bar already shipped for zh-CN. Add them to locales_config.xml —
the single source of truth for the in-app language picker and Android's per-app
language settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:40:10 +02:00
a96d8b6de9 Merge remote-tracking branch 'origin/main' into release/v2.15.0 2026-07-15 19:39:08 +02:00
94b9eeaa78 Merge pull request 'Translations update from Weblate' (!78) from weblate-bot/calendula:weblate-calendula-strings 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) Has been skipped
Reviewed-on: #78
2026-07-15 17:38:51 +00:00
Bazyli Cyran
85fb091e22 Translated using Weblate (Polish)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m31s
Currently translated at 58.7% (252 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-15 17:34:15 +00:00
2139f8729c Merge remote-tracking branch 'origin/main' into release/v2.15.0 2026-07-15 19:28:22 +02:00
98a48aa795 docs(changelog): note the agenda date localization and gutter alignment fixes
Also correct the #35 entry — the empty-today marker is a card in the app but a
plain line in the widget, so call it a "note" rather than a card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:27:20 +02:00
d255d232b9 Merge pull request 'Translations update from Weblate' (!76) from weblate-bot/calendula:weblate-calendula-strings 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) Has been skipped
Reviewed-on: #76
2026-07-15 17:24:26 +00:00
9999fcdd2a Merge pull request 'feat(detail): duplicate an event as a new one (#52)' (!77) from feat/duplicate-events into release/v2.15.0
Reviewed-on: #77
2026-07-15 17:23:44 +00:00
953ffdff97 fix(day): align the hour-gutter labels with the top bar hamburger
The day view's 48dp edge-to-edge hour gutter centred its labels at 24dp, 4dp
left of the hamburger. Give the gutter content the same 8dp start inset as the
week view so the labels centre on the hamburger (4dp bar inset + 24dp half icon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:19:42 +02:00
5377a2b466 fix(calendar): align the week-number gutter with the top bar hamburger
The month grid was inset 4dp while its weekday header (and the loading grid) used
8dp, so the week-number column — and the day cells under their labels — sat 4dp
left of where they should. Bring the grid to 8dp: the gutter centre now lands on
the hamburger (4dp bar inset + 24dp half icon), and day cells sit under their
weekday labels.

The week view's header badge and hour labels had the same drift (a 48dp edge-to-
edge gutter centres its content at 24dp, not 28dp). Its top section background
bleeds full-width when scrolled, so instead of insetting the whole content, give
just the gutter content an 8dp start inset to centre it on the hamburger too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:15:51 +02:00
e73148dc6c fix(widget): keep the empty-today line plain, not a card
The agenda widget's event rows are plain (a colour stripe + text, no card), so
the rounded "No more events today" surface looked out of place. Render it as a
muted line indented to the event titles instead, matching the widget's style.
The in-app agenda keeps its coffee-cup card, where event rows are cards too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:15:51 +02:00
a19772e3a7 docs(changelog): note the duplicate-event action (#52)
All checks were successful
Translations / check (pull_request) Successful in 26s
CI / ci (pull_request) Successful in 9m15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:11:34 +02:00
b3386eff43 Merge branch 'release/v2.15.0' into feat/duplicate-events 2026-07-15 19:10:29 +02:00
79e74d9995 feat(detail): duplicate an event as a new one (#52)
Add a Duplicate action to the event detail top bar that opens the shared
event form seeded with a copy of the event as a new, unsaved event, so a
non-recurring event can be re-created with just the day and time changed
instead of re-entering every field.

The copy reuses the existing prefilled-create overlay (createEvent), so it
becomes an independent event with the default reminder applied. Recurrence
is dropped — a duplicate is a single event; the edit form still exposes a
recurrence picker for anyone who wants a series. The occurrence's own times
carry over unchanged. Duplicate is offered for any loaded event, including
read-only ones (WebCal, birthdays): the source calendar is kept only when
it's writable, otherwise the copy resolves to the first writable calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:10:23 +02:00
396a5610aa fix(agenda): localize dates and refine the range bar
Localize every agenda date via a new localizedDateFormatter helper that lays a
field skeleton out in the locale's own order (Android best-pattern), instead of
a hardcoded day-month-year layout: the range-window summary, the screen's day
headers, and the widget's day headers. This also fixes the window mixing two
orders (e.g. "15 Jul – Aug 13, 2026").

Refine the range bar: the banner drops the range name (it already sits on the
selector button beside it) and shows just the concrete dates; the selector keeps
a subtle neutral surface tint — distinct from the top-bar view switcher's
secondary container so the two don't compete — and its right edge lines up with
the switcher. The anchored empty-today card takes a single event row's resting
corner radius.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:02:37 +02:00
b9329f6fb6 feat(agenda): anchor today with an empty-day card (#35)
Add an "Always show today" setting (Settings → Agenda, on by default) that
keeps today as the first entry in both the Agenda screen and its home-screen
widget even once nothing is left today. Under today's normal header a small
"No more events today" card appears — the coffee-cup empty-state motif in the
app, a rounded surface in the widget — so the first rows you see are clearly
today's rather than a future day's.

The anchor is a pure, JVM-tested helper (anchorTodayIfMissing) applied after
past-event filtering; in-app it only kicks in when the window starts on today,
never on a jumped-to date. The widget reads the pref reactively via per-instance
Glance state, mirroring the range/past-event settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:39:27 +02:00
1b731a4ab0 docs(changelog): note the ACTION_EDIT intent and .ics MIME types
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:25:56 +02:00
ab631365b2 feat(intent): handle ACTION_EDIT and broaden .ics MIME types
Round out the calendar-intent surface toward AOSP/Etar parity — the app
already handled VIEW (date + event), INSERT, and .ics open/share, but was
missing the edit action and the alternate .ics MIME labels.

- ACTION_EDIT on content://com.android.calendar/events/<id> now opens the
  event in the edit form (previously only VIEW → read-only detail existed).
  An assistant, task app, or widget can hand an event to Calendula to edit.
  A bare EDIT URI with no occurrence extras falls back to the event row's
  own DTSTART/DTEND, mirroring the #48 view-event fallback.
- ACTION_EDIT with no event id (AOSP's "edit a new event") maps to the same
  prefilled create form as ACTION_INSERT.
- The .ics VIEW/SEND filters now also accept text/x-vcalendar (vCalendar
  1.0 / .vcs) and application/ics — the alternate labels the same calendar
  data arrives under from some file/mail apps (matches Etar's ImportActivity).

Deliberately excluded: webcal:// / http(s) remote-calendar subscription
(needs INTERNET, which the app doesn't have) and the Google-web-link handler
(Google-specific + network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:23:40 +02:00
9aa370d583 Merge remote-tracking branch 'origin/main' into release/v2.15.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
2026-07-15 17:23:31 +02:00
iker Contreras
8cd75716ae Translated using Weblate (Spanish)
All checks were successful
Translations / check (pull_request) Successful in 27s
CI / ci (pull_request) Successful in 10m52s
Currently translated at 100.0% (429 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-14 02:41:02 +00:00
Thomas Tref
4feccf2008 Translated using Weblate (French)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m14s
Currently translated at 30.5% (131 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-13 23:05:35 +00:00
23e9a5a35b Merge pull request 'fix(reminders): show the day for reminders on another day (#46)' (!75) from fix/reminder-day-context into release/v2.15.0
Reviewed-on: #75
2026-07-13 15:35:03 +00:00
38a35be0f0 fix(reminders): show the day for reminders on another day (#46)
All checks were successful
CI / ci (pull_request) Successful in 5m31s
Translations / check (pull_request) Successful in 5s
A reminder fired ahead of an event on a different day showed only the
event's time (e.g. "09:30 – 10:00"), making it look like it was
happening today. reminderTimeText now prefixes timed events with a
relative day: "Tomorrow"/"Yesterday", the short weekday for another day
this week, or the exact date for anything further out.

The this-week boundary honours the user's "week starts on" setting: the
resolved first day of week is threaded through from ReminderNotifier, so
e.g. a Sunday reads as next week under a Sunday-start locale. All-day
events keep their explicit date (never ambiguous), and cross-midnight
timed events keep both explicit dates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:25:51 +02:00
f9f0572ec5 Merge pull request 'docs(changelog): add the custom snooze duration entry (#40)' (!74) from docs/changelog-snooze into release/v2.15.0
Reviewed-on: #74
2026-07-13 14:39:18 +00:00
0221972e6d docs(changelog): add the custom snooze duration entry (#40)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 12s
The custom snooze duration shipped in 3c97673 but had no changelog entry;
add it to the Unreleased section so it's in the 2.15.0 release notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:38:35 +02:00
86ea72d56f Merge pull request 'feat(edit): move an event to another calendar (#39)' (#73) from feat/change-event-calendar into release/v2.15.0 2026-07-13 14:30:07 +00:00
114db7939c ci(translations): run on every PR so the required check always reports
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 2m27s
The release/* branch protection requires the "Translations / check" status,
but the workflow was path-filtered to translation resources. A code-only PR
targeting a release branch never touches those, so the workflow never ran,
never posted its status, and the required check stayed pending forever —
permanently blocking the merge (only PRs that happened to change strings could
satisfy it).

Drop the path filter so it runs on every PR, mirroring the always-on `ci` job.
The parity check is SDK-free and passes when the committed translations are
consistent, so running it on unrelated PRs is effectively free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:27:10 +02:00
4503847c0d feat(edit): move an event to another calendar (#39)
All checks were successful
CI / ci (pull_request) Successful in 8m40s
The calendar row in the editor is now tappable when editing an existing
event: picking a different calendar moves the event there on save, rather
than forcing a delete-and-recreate.

CALENDAR_ID is sync-adapter-owned and can't be updated in place, so the
move is copy+delete: the master row is re-inserted on the target calendar
(preserving UID_2445 so backup dedup and sync identity survive), its
reminders and editable guests are copied, and — for a recurring series —
every exception is replayed against the new master (modified occurrences
via CONTENT_EXCEPTION_URI, cancellations as STATUS_CANCELED). The user's
field edits are then applied with the normal series update. Everything on
the new side is built before the source is deleted (post-before-delete),
with a rollback of the copy on any failure, so a move is all-or-nothing.

A calendar change forces whole-series scope, so it skips the recurring
scope dialog. Managed special-dates calendars stay locked. Colour is not
carried across (a raw/keyed colour may be invalid on the target account),
matching the existing calendar-switch behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:11:32 +02:00
95c5fe76e6 Merge pull request 'Release 2.14.1' (!72) from release/v2.14.1 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Successful in 8m5s
Reviewed-on: #72
2026-07-13 13:48:22 +00:00
34fa9c9c3e chore(release): cut 2.14.1
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m43s
Move the Unreleased fixes (#47, #48, #49, #22) under a 2.14.1 heading, bump
versionName/versionCode to 2.14.1/21401, and sync the F-Droid per-version
changelog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:32:16 +02:00
53793bfb68 docs(changelog): describe the .ics reminder prompt accurately (#49)
The prompt is raised whenever a default is configured and the file's reminders
differ from it — including when the file carries none at all, not only when it
brings its own. On-device verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:24:18 +02:00
9c7c8cb03a docs(changelog): note the #47 fix covers local calendars too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:01:37 +02:00
4fea176e28 fix: drop occurrences via EXDATE on calendars with no _sync_id (#47)
The cancelled-exception fix works on synced calendars but not local ones. A
cancelled exception only attaches to its parent through ORIGINAL_SYNC_ID; a
local event has no _sync_id, so the link never forms and the provider's
expansion of the *parent* collapses — every other occurrence disappears, which
is the original #47 corruption, just on a different calendar type. Verified
on-device both ways: a DAVx5 series survives a single-occurrence delete, the
same series on a LOCAL calendar vanishes entirely.

deleteOccurrence now branches on _sync_id. Synced events keep the (verified)
exception path. Events without one — local calendars, and synced events not yet
pushed — add the occurrence to the master's EXDATE, which needs no parent link
and is the canonical iCalendar way to drop one; a sync adapter carries it
upstream unchanged if the calendar later syncs.

Two provider quirks shape the write (both observed on a Pixel):
- An EXDATE-only update is not treated as a recurrence change: the expanded
  Instances rows are left alone, so the occurrence stays visible. The
  time/recurrence set has to ride along to force re-expansion.
- DTSTART alone is worse — the provider then recomputes lastDate as if the event
  were a single instance and collapses the series to its first occurrence.
  DTSTART + DURATION + RRULE + zone together re-expand it correctly.

This path is reached in normal use: Calendula's own contact special-date
calendars are local and hold all-day yearly series, so deleting one birthday
occurrence went through it. All-day series take the VALUE=DATE EXDATE form.

Adds pure buildOccurrenceExdateValues + JVM tests (timed, append, duplicate
fold, all-day).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:01:11 +02:00
cf9492c7ba fix(detail): derive series length from DURATION when DTEND is null (#48)
A recurring series row carries DURATION, not DTEND, so EventDetailMapper's
end == begin fallback rendered it zero-length. That was harmless while every
caller supplied per-occurrence times from Instances, but the bare
content://…/events/<id> VIEW intent added in #48 names no occurrence and
keeps the row's own times — so a series opened from a third-party widget
without begin/end extras showed as "10:00 – 10:00".

Read DURATION in the detail projection and derive the end from it, the same
way SearchMapper and IcsExportMapper already do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 12:55:05 +02:00
e6def9e5f7 docs(changelog): log the #47/#48/#49 fixes under Unreleased
The 2.14.1 branch carried four fixes but only the CalDAV colour picker
(#22) had a changelog entry. Adds the recurring single-instance delete
fix (#47), the external VIEW-intent handling (#48) and the default
reminder on ACTION_INSERT / .ics import prompt (#49), plus their issue
link definitions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 12:34:08 +02:00
974185f354 Merge pull request 'fix(edit): default reminder for ACTION_INSERT events + import prompt (Codeberg #49)' (!71) from fix/insert-intent-default-reminder into release/v2.14.1
Reviewed-on: #71
2026-07-12 10:03:36 +00:00
6aacdd9111 feat(edit): offer default reminder on .ics import instead of auto-applying (#49)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m21s
Split the two prefill paths that share openImported(): an ACTION_INSERT
intent still auto-applies the settings default (it carries no reminder
semantics), but a .ics file — which owns its reminders — no longer silently
decides. It keeps the file's reminders and raises a one-time prompt
("This event was imported with N reminder(s) — apply your default?") so the
user chooses. The prompt is skipped when there's no real choice: no default
configured, or the file already carries exactly it.

openImported() now takes an ImportSource; CalendarHost tags the overlay
Insert vs File. Accepting swaps in the default and reveals the section;
declining (or dismissing) keeps the file's own reminders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:57:38 +02:00
4f263d00fe fix(edit): apply default reminder to ACTION_INSERT events (#49)
External ACTION_INSERT launches (e.g. Google Maps' "add to calendar",
the Todo Agenda widget) share the single-event .ics prefill channel:
CalendarHost routes requestedInsertForm as importForm, so EventEditScreen
calls openImported(), which froze reminders as touched to respect a file's
own VALARMs. But an insert intent carries no reminders, so the empty freeze
just suppressed the configured settings default — the event opened (and
saved) with no reminder.

Make the freeze follow the source, not the path: a form that carries its
own reminders (an .ics with VALARMs) still freezes them; a form with none
(every insert intent, and an .ics without VALARMs) falls back to the
settings default via applyDefaultReminder(), exactly like openNew(). An
intent that did carry reminders still wins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:46:51 +02:00
bda002684c Merge pull request 'fix(edit): curate the CalDAV colour picker (#22)' (!70) from fix/caldav-color-picker-v2 into release/v2.14.1
Reviewed-on: #70
2026-07-12 09:40:18 +00:00
b6bcd195b0 fix(edit): curate the CalDAV colour picker (#22)
All checks were successful
CI / ci (pull_request) Successful in 5m18s
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the event-colour picker showed a full screen
of alphabetically-scrambled, partly duplicated swatches.

Curation now runs in the space the picker actually paints — every swatch
is softened through pastelize, which pins lightness and caps saturation,
so the raw palette's lightness axis is invisible on screen. Judging
distinctness there: colours that paint identically collapse to one
(folding aliases, dark/light shades of a hue, and the neutrals together),
oversized palettes drop washed-out neutral-origin tints and thin by CIE76
ΔE in painted Lab, and survivors sort continuously by painted hue with the
wheel cut at its single widest gap. The CSS3 dump lands at ~33 distinct,
rainbow-ordered swatches; small hand-picked palettes (Google's) pass
through untouched. Every surviving swatch keeps its provider colour key so
picks still round-trip through sync.

This revives work stranded on fix/caldav-color-picker (never merged) and
adapts it to the floret-kit extraction of pastelize: the curation's
painted-space transform now lives self-contained in domain/pastelArgb as a
mirror of floret's pastelize shaping, rather than the two sharing one
function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:22:04 +02:00
9fb4502ed0 Merge pull request 'fix: open existing events from external VIEW intents (Codeberg #48)' (!69) from fix/widget-view-event-intent into release/v2.14.1
Reviewed-on: #69
2026-07-12 09:09:24 +00:00
d398c72005 Merge pull request 'fix: cancel only the tapped occurrence on single-instance recurring delete (Codeberg #47)' (!68) from fix/recurring-single-delete into release/v2.14.1
Reviewed-on: #68
2026-07-12 09:09:07 +00:00
9d718e0f51 fix: open existing events from external VIEW intents (#48)
All checks were successful
CI / ci (pull_request) Successful in 10m20s
Follow-up to #30. v2.14.0 handles ACTION_INSERT (the widget "+"), but
tapping an existing event in a third-party widget (e.g. Todo Agenda) never
offered Calendula, because nothing handled ACTION_VIEW on
content://com.android.calendar/events/<id>.

- Manifest: add a VIEW intent-filter matched by the provider's item MIME
  type (vnd.android.cursor.item/event), mirroring AOSP Calendar and the
  sibling INSERT dir/event filter. A content: VIEW intent carries the
  resolved type, so a path-only filter wouldn't match it.
- MainActivity.viewEventKeyOrNull: parse the events URI into the existing
  occurrence detail-key channel (the one reminder taps use). Occurrence
  times ride as EXTRA_EVENT_BEGIN_TIME/END_TIME when the launcher supplies
  them; a bare URI omits them.
- EventDetailViewModel: a NO_OCCURRENCE_TIME sentinel makes loadDetail keep
  the event row's own DTSTART/DTEND for a bare URI instead of overriding to
  the epoch (would otherwise render at 1970).

Needs on-device verification (intent-filter matching + the widget's actual
extras).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:01:53 +02:00
8ddc11159b Merge pull request 'Translations update from Weblate' (!66) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Renovate / renovate (push) Successful in 1m8s
Reviewed-on: #66
2026-07-12 08:50:03 +00:00
2218c11d3f fix: cancel only the tapped occurrence on single-instance delete (#47)
All checks were successful
CI / ci (pull_request) Successful in 6m21s
"Delete only this event" on a recurring series wrote a cancelled
exception carrying just ORIGINAL_INSTANCE_TIME + STATUS_CANCELED. Without
DTSTART + DURATION the provider clones the master *with its RRULE intact*
and cancels the whole clone, so every other occurrence vanished, the
target survived as a "cancelled" ghost, and re-deleting toggled the
series back — exactly the reported corruption.

Anchor the exception as a single instance (DTSTART + DURATION + zone +
all-day, read from the series row) so the provider clears the inherited
RRULE and cancels only that occurrence — the same discipline the edit
path already documents (Codeberg #16). Also filter STATUS_CANCELED out of
the instances grid query so the cancelled occurrence disappears instead
of lingering as a tappable ghost (NULL status is kept — a normal event).

Extracts the exception ContentValues into a pure buildOccurrenceCancelValues
helper with JVM tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:36:35 +02:00
Kachelkaiser
6736bae5ee Translated using Weblate (German)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m17s
Currently translated at 100.0% (424 of 424 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-10 23:01:07 +00:00
60aa889c57 Merge pull request 'feat(settings): custom snooze duration, extract editor card to floret-kit' (!67) from feat/custom-snooze-duration into release/v2.15.0
Reviewed-on: #67
2026-07-08 16:10:32 +00:00
3c9767387b feat(settings): custom snooze duration, extract editor card to floret-kit
All checks were successful
CI / ci (pull_request) Successful in 10m14s
The Snooze-duration setting gains a Custom… option: a new single-select
SnoozeDurationPicker keeps the minute presets and adds a Custom row that
expands an amount field with a Minutes/Hours toggle, so any delay is settable
(not just the fixed presets). Closes the settings half of the snooze request.

The three presets-plus-custom editors (reminder default, agenda range, snooze)
now delegate to floret-kit's new CustomAmountEditor instead of each duplicating
the tonal editor card — the app keeps its domain math, strings and labels.

Re-pin the floret-kit submodule to the branch commit carrying CustomAmountEditor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:02:13 +02:00
Sam Gamgee
21d1726d7a Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m38s
Currently translated at 100.0% (410 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-07 14:42:36 +00:00
1e7ee5b98a Merge pull request 'release: v2.14.0 — Day view on date-header tap' (!61) from release/v2.14.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 8m51s
Reviewed-on: #61
2026-07-07 14:42:29 +00:00
a82df3f6d0 Merge branch 'main' into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 5m17s
2026-07-07 14:37:07 +00:00
e0e3eb73b9 chore: pin floret-kit to v0.1.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m39s
Move the submodule pin from a loose main commit to the tagged v0.1.0
release (same tree content), so the from-source F-Droid build tracks a
stable, traceable kit version instead of a rolling commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:59:28 +02:00
8a7a0af207 chore(fastlane): refresh screenshots, add it/es store listings
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m36s
Recapture all phone screenshots on the Pixel 10 Pro (Android 16) at a
uniform 1280x2856 across every locale, showing the current UI with sample
events: week, month, day, event detail, agenda, and the calendar-access
onboarding. Replaces the old 05-edit shot with 05-agenda (the light theme
has no standalone edit screen) for en-US and de-DE.

Add fully localized store metadata (title, summary, full description) and
per-locale icon for Italian (it-IT) and Spanish (es-ES), matching the
existing en-US/de-DE listings; the app UI is already translated for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:45:44 +02:00
71a652ce3b feat: full-screen selection pickers (retire OptionCard modals, bar actions)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m8s
Unify the app's "choose one" surfaces on the full-screen picker style
(floret-kit FullScreenPicker/OptionPicker) instead of the OptionCard modal
dialogs, for consistency across the app:

- Event editor: visibility + add-field -> OptionPicker; reminder, recurrence
  rule, and colour -> FullScreenPicker, with the custom-value Add / OK and the
  colour Reset carried in the app-bar via the picker's new `actions` slot; the
  save-conflict chooser -> full-screen.
- The recurring scope choosers stay compact OptionCard popups — saving an edit
  to, or deleting, a recurring event — since a quick 2-3 option decision reads
  better as a popup than a near-empty full screen.

Bumps the floret-kit pin (55ad536 -> e1919ca) for the FullScreenPicker
`actions` passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:23:23 +02:00
a8aeae5f32 Merge pull request 'Translations update from Weblate' (!63) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #63
2026-07-06 20:40:51 +00:00
Ulisse Perusin
5771b603f2 Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m29s
Currently translated at 99.0% (406 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-06 20:20:20 +00:00
Kachelkaiser
5725989aff Translated using Weblate (German)
Currently translated at 100.0% (410 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-06 20:20:19 +00:00
93 changed files with 3957 additions and 658 deletions

View File

@@ -3,13 +3,15 @@ name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
#
# Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is
# skipped on unrelated PRs and never posts its status, which leaves that
# required check pending forever and blocks the merge of any code-only PR into a
# release/* branch. The check itself is cheap and simply passes when the
# committed translations are consistent, so always running it costs nothing.
on:
pull_request:
paths:
- 'app/src/main/res/values*/strings.xml'
- 'app/src/main/res/xml/locales_config.xml'
- 'scripts/check_translations.py'
- '.gitea/workflows/translations.yaml'
concurrency:
group: translations-${{ github.ref }}

View File

@@ -5,6 +5,118 @@ 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).
## [2.15.0] — 2026-07-15
### Added
- Show raw calendar colours. Calendula normally softens each calendar and event
colour toward a theme-fitting pastel so harsh sync colours read well on both
light and dark; a new **Soften calendar colours** setting (Settings → Design,
on by default) lets you turn that off and paint the exact colours your calendar
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
@leonp5 for the report ([#36]).
- Readable titles on dark event colours. An event bar's title now shows in white
on a dark colour and near-black on a light one, chosen automatically from the
colour's brightness, so a deep blue or purple event is legible at a glance in
the busy Week and Month views instead of dark-on-dark. This applies whether or
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
- A custom snooze duration. The **Snooze duration** setting (Settings →
Notifications) gains a **Custom…** option next to the minute presets: pick any
amount and switch between minutes and hours, so a snoozed reminder comes back
after exactly the delay you want instead of only a preset one ([#40]).
- Move an event to another calendar. When editing an existing event, the
calendar row is now tappable — pick a different calendar and saving moves the
event across, instead of having to delete it and recreate it elsewhere.
Recurring series move as a whole, keeping their individually-edited and
cancelled occurrences, and any reminders and guests come along too. A calendar
can't simply be reassigned underneath an event, so Calendula recreates it on
the target and removes the original — the same approach other calendar apps
take. Thanks to @prismplex for the suggestion ([#39]).
- Open an event straight into the edit form from another app. Calendula already
answered the "new event" and "open this event" hand-offs from other apps and
widgets; it now also answers the "edit this event" one, so an assistant, task
app, or widget can send an existing event to Calendula and land on its edit
screen rather than the read-only details. A hand-off with no event attached
opens the same prefilled create form as "new event". Calendula also recognises
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a "No more events today" note appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
- Duplicate an event. The event details now carry a **Duplicate** action that
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
a one-off like a shift or an appointment can be recreated by just changing the
day and time instead of re-typing every field. The copy keeps the original's
time, and its title, location, notes, colour, guests and reminders come along;
it's saved as its own single event (any repeat is left off — add one in the
editor if you want it). Duplicate works from read-only calendars too, dropping
the copy into a writable one. Thanks to @internet-rando for the suggestion
([#52]).
- French and Polish, in early form. Calendula has started speaking French and
Polish, both contributed as community translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are partway there, so untranslated parts still show in English until they
fill out — you can already pick either under Settings → Language or in Android's
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
(Polish) for getting them started; help finishing them is very welcome.
### Fixed
- Reminders for events on another day no longer read as if they were today. A
reminder fired ahead of time — say, the day before — used to show only the
event's time, making it look like it was happening now. The notification now
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
- Agenda dates now read in your locale's format. The agenda's range bar and its
day headers used a fixed day-month-year layout — and the range span even mixed
two orders (e.g. "15 Jul Aug 13, 2026") — instead of following your language's
conventions. Dates across the agenda and its widget now use your locale's own
field order, matching the rest of the app. The range bar also no longer repeats
the range's name from the selector button beside it, showing just the dates.
- Calendar gutters line up with the menu button. The Month view's week-number
column, and the Week and Day views' hour labels, sat a few pixels left of the
hamburger menu above them; they now line up with it. In Month view the day
cells also sit squarely under their weekday letters.
## [2.14.1] — 2026-07-13
### Fixed
- Deleting one occurrence of a repeating event no longer breaks the series.
Choosing "This event" when deleting an occurrence of a recurring event could
wipe out every *other* occurrence while leaving the one you deleted behind as
a stale, still-tappable ghost — and deleting it again brought the series back.
A single-occurrence delete now removes exactly that occurrence and leaves the
rest of the series untouched, and the deleted occurrence disappears from the
grid straight away. This holds on every kind of calendar, including the
on-device ones Calendula keeps for contact birthdays and anniversaries, where
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
Calendula to answer the "new event" hand-off from other apps and widgets; now
it also answers the "open this event" one, so tapping an existing event in a
widget such as Todo Agenda offers Calendula and lands on that event's details.
Thanks to @bushrang3r for the report ([#48]).
- Events created from other apps get your default reminder. An event handed over
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
widget's "+" — opened with no reminder at all, ignoring the default set in
Settings. It now starts with your default reminder, the same as an event you
create in Calendula. An event opened from an `.ics` file is treated differently,
because the file has its own say: Calendula keeps whatever reminders it carries
(including none at all) and asks you once whether to apply your default instead
— it never quietly overrides the file. If you have no default set, it doesn't
ask ([#49]).
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
app (such as DAVx5), the event colour picker showed every colour the account
publishes — nearly 150 swatches in alphabetical order, many of them
duplicates or near-identical shades. The picker now shows only visually
distinct colours, arranged as a rainbow; near-duplicate shades and the
washed-out neutrals are folded away so no two swatches look alike. Picked
colours still sync exactly as before, and calendars with hand-picked
palettes (like Google's) are unaffected. Thanks to @ptab for the report
([#22]).
## [2.14.0] — 2026-07-06
### Added
@@ -865,12 +977,23 @@ automatically, with zero telemetry and no internet permission.
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52

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 = 21400
versionName = "2.14.0"
versionCode = 21500
versionName = "2.15.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -97,32 +97,74 @@
<data android:mimeType="time/epoch" />
</intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). -->
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
The three MIME types cover the common labels the same calendar
data arrives under: iCalendar 2.0 (text/calendar), the older
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
mail apps emit — Android cross-products the scheme and mimeType
tags, so each MIME is accepted on both schemes (matches Etar). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:mimeType="text/calendar" />
<data android:scheme="file" android:mimeType="text/calendar" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter>
<!-- Receive a .ics shared from another app. -->
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</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). -->
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
to the same prefilled create form. -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter>
<!-- Edit an existing event another app/assistant/widget points at:
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
AOSP fires it. Opens the occurrence in the edit form (not the
read-only detail — that's the VIEW filter above). Matched by the
provider's item MIME type, like the VIEW filter. The occurrence's
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
supplied (MainActivity.editEventKeyOrNull). -->
<intent-filter>
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Open an existing event another app/widget points at (e.g. tapping
an event in the Todo Agenda widget): ACTION_VIEW on
content://com.android.calendar/events/<id>, the way AOSP fires it.
Matched by the provider's item MIME type, not the path — a
content: VIEW intent carries the resolved type
(vnd.android.cursor.item/event) and a path-only filter wouldn't
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
EXTRA_EVENT_END_TIME when the launcher supplies them
(MainActivity.viewEventKeyOrNull, issue #48). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data
android:name="android.app.shortcuts"

View File

@@ -29,9 +29,11 @@ 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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.floret.components.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.domain.FontRole
@@ -69,6 +71,11 @@ class MainActivity : AppCompatActivity() {
// CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(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.
@@ -90,10 +97,11 @@ class MainActivity : AppCompatActivity() {
}
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -133,6 +141,7 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
LocalSoftenColors provides settings.softenColors,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
@@ -144,6 +153,8 @@ class MainActivity : AppCompatActivity() {
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
)
}
// A persistent corner marker so a debug build is never
@@ -178,10 +189,11 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
/**
@@ -202,13 +214,19 @@ class MainActivity : AppCompatActivity() {
}
/**
* 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].
* A prefilled new-event form from an external launch asking us to create an
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* event", i.e. create). 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
// ACTION_EDIT on an existing event routes to the edit form instead
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
val isCreate = action == Intent.ACTION_INSERT ||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
if (!isCreate) return null
return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
@@ -293,6 +311,55 @@ class MainActivity : AppCompatActivity() {
)
}
/**
* The detail key for an external "open this event" — ACTION_VIEW on
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
* same occurrence-key channel as reminder taps. The launcher passes the
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
* rather than rendering at the epoch.
*/
private fun Intent.viewEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_VIEW) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "events") return null
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
return longArrayOf(
eventId,
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
)
}
/**
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
* an assistant, task app, or widget that wants to open the event for editing
* rather than viewing). Opens it in the edit form. Reuses the same
* occurrence-key channel as reminder/view taps; the caller passes the
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
* [EventEditViewModel.openForEdit] falls back to the event row's own
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
*/
private fun Intent.editEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_EDIT) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "events") return null
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
return longArrayOf(
eventId,
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
)
}
companion object {
// The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.

View File

@@ -18,10 +18,13 @@ import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
@@ -63,10 +66,12 @@ interface CalendarDataSource {
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
* account exposes no palette (most local calendars, some CalDAV) — the
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
* which a synced calendar may drop on its next sync.
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* Empty when the account exposes no palette (most local calendars, some
* CalDAV) — the signal that a custom colour can only be written as a raw
* `EVENT_COLOR`, which a synced calendar may drop on its next sync.
*/
fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -176,6 +181,25 @@ interface CalendarDataSource {
allDayReminderTimeMinutes: Int,
)
/**
* Move an event (for recurring events: the whole series, with its modified
* and cancelled occurrences) to [targetCalendarId], returning the new
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
* place, so this is copy+delete: the row is re-inserted on the target (its
* `UID_2445` preserved), its exceptions, reminders and editable guests
* replayed, the [original]→[updated] field edits applied, then the source
* deleted. The source is removed only once the copy fully succeeds; a failure
* before that rolls the new event back, leaving the original untouched.
* [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/**
* Change a single occurrence of a recurring event by inserting a
* modified-occurrence exception at [beginMillis] (the occurrence's
@@ -492,7 +516,13 @@ class AndroidCalendarDataSource @Inject constructor(
return resolver.query(
uri,
InstanceProjection.COLUMNS,
null, null,
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
"${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
@@ -611,7 +641,7 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?.sortedBy { it.key }
?.curatedForPicker()
?: emptyList()
}
@@ -876,6 +906,190 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// CALENDAR_ID can't be updated in place, so re-create the event on the
// target calendar and delete the source. Everything that identifies or
// hangs off the event is copied first; the source row is removed only
// once the copy (including its exceptions) is complete — a failure
// anywhere before that rolls the new event back, so the move is
// all-or-nothing and never leaves a half-copied duplicate.
val master = queryMoveMaster(eventId)
?: throw WriteFailedException("read event to move id=$eventId")
// Keep the source UID so an .ics backup dedups and a sync adapter can
// recognise the moved event; mint one only if the source carried none.
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
val newEventId = resolver.insert(
CalendarContract.Events.CONTENT_URI,
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
)?.let(ContentUris::parseId)
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
try {
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
insertAttendees(newEventId, editableAttendees(eventId))
copyExceptions(fromEventId = eventId, toEventId = newEventId)
// Apply the user's field edits exactly as a same-calendar "all
// events" save would: the moved master carries the source's values,
// so the dirty diff against [original] writes only what changed
// (including a time or rrule edit made in the same save).
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
} catch (t: Throwable) {
// Undo the partial copy so a failed move leaves nothing behind; the
// source is still intact (it's deleted only past this point).
runCatching { deleteEvent(newEventId) }
throw t
}
deleteEvent(eventId)
return newEventId
}
/** The master row of the event to move, as a verbatim-insert snapshot. */
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
MoveMasterProjection.COLUMNS,
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
MoveMaster(
uid = r.getString(MoveMasterProjection.IDX_UID),
event = MasterEventSnapshot(
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
status = r.getInt(MoveMasterProjection.IDX_STATUS)
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
),
)
}
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
CalendarContract.Reminders.CONTENT_URI,
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(fromEventId.toString()),
null,
)?.use { c ->
while (c.moveToNext()) {
val values = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, toEventId)
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
put(CalendarContract.Reminders.METHOD, c.getInt(1))
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
}
}
} ?: Unit
/**
* The event's editable guests as [EventAttendee]s — the rows the move
* re-creates. Mirrors the edit form's filter: the organizer and resource
* rows (backend-owned, not user-editable) and any without an email are
* dropped; the response status resets to "invited" on re-insert.
*/
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
.filter {
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
}
.mapNotNull { a ->
a.email?.takeIf { it.isNotBlank() }?.let { email ->
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
}
}
/**
* Replay every exception of the series [fromEventId] against the moved series
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
* exception, a modified one is re-inserted with its fields (then its reminders
* reconciled and editable guests copied). No-op for a non-recurring event.
*/
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
queryExceptionRows(fromEventId).forEach { ex ->
if (ex.isCancelled) {
val values = ContentValues().apply {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
values,
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
} else {
val uri = resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
buildCopiedExceptionValues(ex).toContentValues(),
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
val newExceptionId = ContentUris.parseId(uri)
// The provider may clone the parent's reminders onto the new
// exception; reconcile to the source exception's exact set so
// they neither double nor drop. Same DTSTART → same all-day
// encoding, so the stored offsets match directly.
reconcileReminders(
newExceptionId,
queryReminders(ex.exceptionEventId).map { it.minutes },
)
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
}
}
}
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
CalendarContract.Events.CONTENT_URI,
ExceptionProjection.COLUMNS,
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
arrayOf(seriesEventId.toString()),
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
)?.use { c ->
c.mapAll {
val r = CursorColumnReader(c)
val status = r.getInt(ExceptionProjection.IDX_STATUS)
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
ExceptionRowSnapshot(
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
status = status,
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
duration = r.getString(ExceptionProjection.IDX_DURATION),
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
location = r.getString(ExceptionProjection.IDX_LOCATION),
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
)
}
} ?: emptyList()
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
@@ -970,6 +1184,8 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events._SYNC_ID,
CalendarContract.Events.EXDATE,
),
null, null, null,
)?.use { c ->
@@ -980,6 +1196,8 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = c.getString(2),
duration = c.getString(3),
allDay = c.getInt(4),
syncId = c.getString(5),
exdate = c.getString(6),
)
} else {
null
@@ -992,6 +1210,9 @@ class AndroidCalendarDataSource @Inject constructor(
val timezone: String?,
val duration: String?,
val allDay: Int,
/** Null on a local calendar (and before a synced event's first push). */
val syncId: String? = null,
val exdate: String? = null,
) {
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
@@ -1182,16 +1403,49 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
// A cancelled exception row hides exactly this occurrence; the sync
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
val values = ContentValues().apply {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
val row = querySeriesRow(eventId)
if (row.syncId == null) {
// No _sync_id — a local calendar, or a synced event not pushed yet.
// A cancelled exception can only attach to its parent through
// ORIGINAL_SYNC_ID, so with none the link never forms and the
// provider's expansion of the *parent* collapses, taking every other
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
// Calendula's own contact special-date calendars are local and hold
// yearly series, so this path is reached in normal use.
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
val updated = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
if (updated == 0) {
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
}
return
}
// A cancelled exception row hides exactly this occurrence; the sync
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
// the full time set (DTSTART + DURATION + zone) so the provider derives a
// single instance rather than cloning the master's RRULE — the same trap
// the edit path documents (Codeberg #16).
val values = buildOccurrenceCancelValues(
originalInstanceMillis = beginMillis,
dtStartMillis = beginMillis,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
val uri = ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
)
resolver.insert(uri, values)
resolver.insert(uri, values.toContentValues())
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
}

View File

@@ -61,6 +61,20 @@ interface CalendarRepository {
*/
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
/**
* Move an event (recurring: the whole series, with its exceptions) to
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
* the new event's `Events._ID`. Copy+delete under the hood
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
* place.
*/
suspend fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long
/**
* Change a single occurrence of a recurring event (exception row with the
* form's values); returns the exception's `Events._ID`.

View File

@@ -157,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.deleteEvent(eventId)
}
override suspend fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
)
}
override suspend fun updateOccurrence(
eventId: Long,
beginMillis: Long,

View File

@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null. Keep the event (end == begin); callers that opened a
// specific occurrence supply the real per-occurrence times from
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
// but dropping the row would make the event un-openable — the same trap as
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
// generic error screen with no way to open the event and fix it. Clamp to a
// zero-length event instead (matching SearchMapper's coerceAtLeast).
// DTEND is null — derive the length from DURATION (as SearchMapper and
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
// both times with the per-occurrence values from CalendarContract.Instances;
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
// intent, issue #48) keeps this row's own times, so the length has to be
// right here or the series renders zero-length. A present-but-backwards
// DTEND is malformed, but dropping the row would make the event un-openable
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
// surface as the generic error screen with no way to open the event and fix
// it. Clamp to a zero-length event instead (matching SearchMapper).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}

View File

@@ -43,10 +43,19 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
* events, seconds otherwise.
*/
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
/**
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
* events (the provider's convention), seconds otherwise. Shared by the write
* paths that need a DURATION but start from a raw millisecond span (the series
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
*/
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
"P${spanMillis / MILLIS_PER_DAY}D"
} else {
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
"P${spanMillis / 1_000L}S"
}
/**
@@ -182,6 +191,215 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color))
}
/**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
* account, so the moved copy inherits the target calendar's colour instead.
*/
internal data class MasterEventSnapshot(
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long,
val dtEndMillis: Long?,
val duration: String?,
val rrule: String?,
val rdate: String?,
val exdate: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val status: Int?,
val location: String?,
val description: String?,
)
/**
* Column values re-creating [snapshot] as a fresh Events row on
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
* RDATE/EXDATE) so the series' generated instances — and therefore the
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
* caller layers the user's field edits on top with a normal series update.
*/
internal fun buildMovedMasterValues(
snapshot: MasterEventSnapshot,
targetCalendarId: Long,
uid: String,
): Map<String, Any?> = buildMap {
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
if (snapshot.rrule != null) {
put(CalendarContract.Events.RRULE, snapshot.rrule)
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
} else {
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
}
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
}
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
/**
* Raw provider snapshot of one exception row of a recurring series (a modified
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
*/
internal data class ExceptionRowSnapshot(
val exceptionEventId: Long,
val originalInstanceMillis: Long,
val isCancelled: Boolean,
val status: Int?,
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long,
val dtEndMillis: Long?,
val duration: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val location: String?,
val description: String?,
)
/**
* Column values replaying a *modified* occurrence [snapshot] against the moved
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
* length travels as DURATION (the provider rejects DTEND on an exception). A
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
* STATUS_CANCELED) — this builder is only for the modified case.
*/
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(
CalendarContract.Events.DURATION,
snapshot.duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration(
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
snapshot.isAllDay,
),
)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
}
/**
* Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
* provider clone the series row and cancel exactly this one instance.
*
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
* DTSTART + DURATION so the provider derives a single instance and clears the
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
* the *whole series* is cancelled and every other occurrence disappears
* (Codeberg #47). The occurrence's length/zone come straight from the series row
* — cancelling never changes them.
*/
internal fun buildOccurrenceCancelValues(
originalInstanceMillis: Long,
dtStartMillis: Long,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
/**
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
* series by adding it to `EXDATE` — the path for events that have **no
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
*
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
* forms, and the provider's expansion of the *parent* collapses — every other
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
* sync adapter carries it upstream unchanged if the calendar later syncs.
*
* The whole time/recurrence set is rewritten alongside it on purpose. The
* provider does **not** treat an EXDATE-only update as a recurrence change: it
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
* `lastDate` as if the event were a single instance, collapsing the series to its
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
*
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
*/
internal fun buildOccurrenceExdateValues(
existingExdate: String?,
occurrenceMillis: Long,
dtStartMillis: Long,
rrule: String?,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
val existing = existingExdate?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
val merged = (existing + stamp).distinct().joinToString(",")
return mapOf(
CalendarContract.Events.EXDATE to merged,
CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.DURATION to duration,
CalendarContract.Events.EVENT_TIMEZONE to timezone,
CalendarContract.Events.ALL_DAY to allDay,
)
}
/**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
* reads off the UTC calendar day.
*/
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
return if (isAllDay) {
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
} else {
"%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.monthValue, utc.dayOfMonth,
utc.hour, utc.minute, utc.second,
)
}
}
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the

View File

@@ -84,6 +84,9 @@ internal object EventDetailProjection {
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.EVENT_COLOR_KEY,
// Recurring rows carry DURATION instead of DTEND; the detail screen
// needs it to render a series opened without a named occurrence.
CalendarContract.Events.DURATION,
)
const val IDX_EVENT_ID = 0
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17
const val IDX_DURATION = 18
}
/**
@@ -186,6 +190,86 @@ internal object SearchProjection {
const val IDX_RDATE = 11
}
/**
* The master/one-off Events row of an event about to be moved to another
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
* generates the same instances, and `UID_2445` so identity survives the move.
*/
internal object MoveMasterProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE,
CalendarContract.Events.EXDATE,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.STATUS,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
)
const val IDX_UID = 0
const val IDX_TITLE = 1
const val IDX_DTSTART = 2
const val IDX_DTEND = 3
const val IDX_DURATION = 4
const val IDX_RRULE = 5
const val IDX_RDATE = 6
const val IDX_EXDATE = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_ALL_DAY = 9
const val IDX_AVAILABILITY = 10
const val IDX_ACCESS_LEVEL = 11
const val IDX_STATUS = 12
const val IDX_LOCATION = 13
const val IDX_DESCRIPTION = 14
}
/**
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
* `DELETED = 0` so provider tombstones aren't replayed.
*/
internal object ExceptionProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
CalendarContract.Events.STATUS,
CalendarContract.Events.TITLE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
)
const val IDX_ID = 0
const val IDX_ORIGINAL_INSTANCE_TIME = 1
const val IDX_STATUS = 2
const val IDX_TITLE = 3
const val IDX_ALL_DAY = 4
const val IDX_DTSTART = 5
const val IDX_DTEND = 6
const val IDX_DURATION = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_AVAILABILITY = 9
const val IDX_ACCESS_LEVEL = 10
const val IDX_LOCATION = 11
const val IDX_DESCRIPTION = 12
}
internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,

View File

@@ -114,6 +114,21 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
}
/**
* Whether raw provider colours are softened toward theme-fitting pastels
* before display (issue #36). Defaults to ON — the historical look, which
* caps harsh sync colours and pins brightness so entries read on both
* themes. Turning it off paints the calendar/event colour exactly as the
* sync source (DAVx5/CalDAV) publishes it.
*/
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
prefs[SOFTEN_COLORS_KEY] ?: true
}
suspend fun setSoftenCalendarColors(enabled: Boolean) {
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
}
/**
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
* strings — "system", "custom", or a bundled font's token — resolved to a
@@ -252,6 +267,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
}
/**
* Whether the agenda (both the in-app screen and the home-screen widget)
* always anchors today at the top with a "nothing left today" placeholder,
* even once today has no remaining events (issue #35). Default ON — makes
* today's events easy to tell apart from a future day's at a glance.
*/
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
}
suspend fun setAgendaShowToday(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
@@ -665,6 +694,7 @@ class SettingsPrefs @Inject constructor(
companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
@@ -673,6 +703,8 @@ class SettingsPrefs @Inject constructor(
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val AGENDA_SHOW_TODAY_KEY =
booleanPreferencesKey("agenda_show_today")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")

View File

@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId
import java.util.Locale
import javax.inject.Inject
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val zone = ZoneId.systemDefault()
val locale = Locale.getDefault()
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
// its shared ISO number (1..7) for the date math in reminderTimeText.
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
val time = reminderTimeText(
beginMillis = alert.beginMillis,
endMillis = alert.endMillis,
isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(),
locale = Locale.getDefault(),
zone = zone,
locale = locale,
is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID)

View File

@@ -1,23 +1,37 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
/**
* The one line of time context in a reminder notification. Pure so it can be
* JVM-tested:
* JVM-tested.
*
* - timed, same day: "09:30 10:00"
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time)
* Timed events that fall on a day other than [today] are prefixed with that
* day, so a reminder fired ahead of time no longer reads as if the event were
* today (issue #46). The prefix prefers natural language and stays short:
*
* - today: "09:30 10:00" (no prefix)
* - tomorrow / yesterday: "Tomorrow, 09:30 10:00" ([tomorrowLabel] / [yesterdayLabel])
* - elsewhere this week: "Thu, 09:30 10:00" (localized short weekday)
* - further out: "16 Jul, 09:30 10:00" (medium date — a weekday
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 12 Jun 2026"
*
* All-day instances store UTC midnights with an exclusive end, so they are
* All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
*/
fun reminderTimeText(
@@ -27,6 +41,10 @@ fun reminderTimeText(
zone: ZoneId,
locale: Locale,
is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
tomorrowLabel: String,
yesterdayLabel: String,
): String {
if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -43,18 +61,70 @@ fun reminderTimeText(
}
val timeFormat = timeOfDayFormatter(is24Hour, locale)
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
return if (begin.toLocalDate() == end.toLocalDate()) {
timeFormat.format(begin) + RANGE + timeFormat.format(end)
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
val prefix = relativeDayPrefix(
day = begin.toLocalDate(),
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
)
if (prefix == null) range else "$prefix, $range"
} else {
// Cross-day: medium date + the chosen short time, joined per side. Built
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
// applies to the time portion too.
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
// applies to the time portion too. The explicit dates already say which
// day, so no relative prefix is layered on top.
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
dateTime(begin) + RANGE + dateTime(end)
}
}
/**
* A short label for [day] relative to [today], or `null` when it *is* today (the
* common case, which needs no prefix). Weekday names are used only within the
* current week — a "next Wednesday" would be indistinguishable from this one, so
* anything past this week falls back to the exact date.
*/
private fun relativeDayPrefix(
day: LocalDate,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
locale: Locale,
dateFormat: DateTimeFormatter,
tomorrowLabel: String,
yesterdayLabel: String,
): String? = when (ChronoUnit.DAYS.between(today, day)) {
0L -> null
1L -> tomorrowLabel
-1L -> yesterdayLabel
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
} else {
dateFormat.format(day)
}
}
/**
* True when [day] and [today] share the same week. The week boundary honours the
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
* with [firstDayOfWeek] falling back to the locale default upstream).
*/
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
val startOfWeek = today.previousOrSame(firstDayOfWeek)
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
}
/** The most recent [target] on or before this date (this date itself when it matches). */
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
val backtrack = (dayOfWeek.value - target.value + 7) % 7
return minusDays(backtrack.toLong())
}
private const val RANGE = " "

View File

@@ -0,0 +1,185 @@
package de.jeanlucmakiola.calendula.domain
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Curates an account's published event palette for the colour picker.
*
* Sync adapters differ wildly in what they publish: Google exposes a
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
* spelling pairs) and dozens of visually indistinguishable whites and grays
* (#22).
*
* Crucially, curation runs against the colour the picker actually *paints*, not
* the raw provider value. The picker softens every swatch through [pastelArgb]:
* it pins lightness to a constant and caps saturation, so the raw palette's
* lightness axis is invisible on screen. Two raw colours that look different —
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
* grays, white) paints as the same pale tint. Judging distinctness in raw
* space, as before, left near-identical painted swatches and stranded the
* neutrals as a run of look-alike "pinks" at the end of the grid.
*
* Three steps, all in painted space:
* 1. Collapse swatches that paint identically to one (alphabetically-first key
* wins, deterministically) — this folds aliases, dark/light shades of a
* hue, and all the neutrals together.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
* then thinned to visually distinct colours: most vivid first, a colour is
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
* colour already kept. Small palettes are already curated by their adapter
* and pass through whole.
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
* with the wheel cut at its single widest empty gap so the one unavoidable
* seam lands in dead space and no hue family is torn across both ends.
*
* Every surviving option keeps its provider [EventColorOption.key], so a pick
* still round-trips through sync.
*/
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
val painted = sortedBy { it.key }
.distinctBy { pastelArgb(it.argb) }
.map { it to Lab.of(pastelArgb(it.argb)) }
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
painted
} else {
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
}
return orderAroundWheel(kept).map { (option, _) -> option }
}
/**
* Orders swatches continuously around the (painted) hue wheel, then cuts the
* circle at its widest angular gap so the single seam lands in empty space
* instead of mid-family. Saturation breaks ties, vivid first.
*/
private fun orderAroundWheel(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
if (swatches.size < 2) return swatches
val byHue = swatches.sortedWith(
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
)
// Split the wheel after the largest empty arc between neighbouring hues;
// the default is the wrap gap (last hue back round to the first), i.e. the
// familiar 0→360 order, and we only rotate away from it for a wider void.
var cutAfter = byHue.lastIndex
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
for (i in 0 until byHue.lastIndex) {
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
if (gap > widestGap) {
widestGap = gap
cutAfter = i
}
}
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
}
/** Greedy max-distance filter: vivid colours stake out clusters first. */
private fun thin(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
for (candidate in byVividness) {
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
}
return kept
}
/**
* The softening the colour picker paints over every swatch: keep the hue, scale
* and clamp saturation into a gentle band, and pin value to a constant so
* nothing screams and everything reads on the surface. Value is fixed here so
* curation is theme-independent — only hue and saturation distinguish painted
* swatches.
*
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
* rather than theme-picked. Curation must reason about the colour the picker
* paints, so the two shapings have to agree: if floret's saturation band or
* curve changes, update this in step.
*/
fun pastelArgb(rawArgb: Int): Int {
val r = ((rawArgb shr 16) and 0xFF) / 255f
val g = ((rawArgb shr 8) and 0xFF) / 255f
val b = (rawArgb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
val hue = when {
delta == 0f -> 0f
max == r -> 60f * (((g - b) / delta) % 6f)
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}.let { if (it < 0f) it + 360f else it }
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
val s = sat.coerceIn(0.25f, 0.65f)
val v = PASTEL_VALUE
val c = v * s
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
val m = v - c
val (rr, gg, bb) = when {
hue < 60f -> Triple(c, x, 0f)
hue < 120f -> Triple(x, c, 0f)
hue < 180f -> Triple(0f, c, x)
hue < 240f -> Triple(0f, x, c)
hue < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
}
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
private const val PASTEL_VALUE = 0.82f
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
private const val CURATION_TRIGGER_SIZE = 36
/** Minimum CIE76 ΔE between surviving painted swatches. */
private const val MIN_DELTA_E = 13.0
/**
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
* is dropped rather than shown as pale filler.
*/
private const val PASTEL_CHROMA_FLOOR = 22.0
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
private class Lab(val l: Double, val a: Double, val b: Double) {
val chroma: Double get() = hypot(a, b)
/** Hue angle in degrees, 0360, around the Lab a-b plane. */
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
fun deltaE(other: Lab): Double =
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
companion object {
fun of(argb: Int): Lab {
fun linear(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
val r = linear(16)
val g = linear(8)
val b = linear(0)
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
val fy = f(y)
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
}
}
}

View File

@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
@@ -74,6 +75,8 @@ fun CalendarHost(
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
// Wait for the persisted default view before seeding the stack, so the app
@@ -172,6 +175,9 @@ fun CalendarHost(
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
// Which channel filled [importForm]: an .ics file (prompt to apply the default
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
var importFormSource by remember { mutableStateOf(ImportSource.File) }
// A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent
// is "restore a backup", not "add this one event". An externally opened .ics
@@ -190,6 +196,7 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
importFormSource = ImportSource.Insert
importForm = requestedInsertForm
onInsertConsumed()
}
@@ -207,6 +214,20 @@ fun CalendarHost(
importForm = null
}
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey
onEditKeyConsumed()
}
}
// A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
@@ -363,6 +384,14 @@ fun CalendarHost(
heldEditKey = key
editKey = key
},
onDuplicate = { form ->
// Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an
// in-app new event (#52).
importFormSource = ImportSource.Insert
importForm = form
detailKey = null
},
)
}
}
@@ -435,6 +464,7 @@ fun CalendarHost(
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null
importFormSource = ImportSource.File
importForm = form
},
)
@@ -443,6 +473,7 @@ fun CalendarHost(
EventEditScreen(
initialDateIso = null,
initialForm = form,
initialFormSource = importFormSource,
onClose = { importForm = null },
onSaved = { importForm = null },
)

View File

@@ -37,6 +37,8 @@ fun RootScreen(
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -88,6 +90,8 @@ fun RootScreen(
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,

View File

@@ -1,10 +1,9 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
/**
* The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header:
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
* (inclusive), for a human-readable header. Both ends use the same day-month
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026"), with the start's
* year shown too only when it differs from the end's.
*/
fun agendaRangeWindowSummary(
range: AgendaRange,
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when (range) {
AgendaRange.Day -> medium.format(javaStart)
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}"
AgendaRange.Day -> dayMonthYear.format(javaStart)
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"
}
}
}

View File

@@ -15,16 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -58,6 +59,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
@@ -65,12 +68,12 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -83,7 +86,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
private val zone = TimeZone.currentSystemDefault()
@@ -106,6 +108,7 @@ fun AgendaScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -170,9 +173,11 @@ fun AgendaScreen(
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
@@ -190,6 +195,7 @@ fun AgendaScreen(
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
@@ -213,9 +219,11 @@ fun AgendaScreen(
}
/**
* A compact tonal pill showing the agenda's current range. Tapping it opens the
* range picker as a session-only override. Filled with the primary container
* while an override is active, so the temporary state is obvious.
* The agenda's current range, tapped to open the range picker as a session-only
* override. Shares the top-bar view switcher's button shape so the two read as a
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
* switcher's secondary container, so it doesn't compete. Fills with the primary
* container only while an override is active, to make that temporary state clear.
*/
@Composable
private fun AgendaRangePill(
@@ -224,43 +232,34 @@ private fun AgendaRangePill(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val container = if (isOverride) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
val content = if (isOverride) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = container,
contentColor = content,
shape = RoundedCornerShape(50),
modifier = modifier.clickable(onClick = onClick),
FilledTonalButton(
onClick = onClick,
shape = MaterialTheme.shapes.large,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = if (isOverride) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
contentColor = if (isOverride) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = agendaRangeLabel(range),
style = MaterialTheme.typography.labelLarge,
)
}
Text(
text = agendaRangeLabel(range),
style = MaterialTheme.typography.labelLarge,
)
}
}
/**
* A header naming the concrete window currently shown, e.g. "Showing all events
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026".
* A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
* lives on the selector button beside it, so it isn't repeated here.
*/
@Composable
private fun AgendaRangeBanner(
@@ -277,8 +276,10 @@ private fun AgendaRangeBanner(
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Just the concrete dates — the range's name ("Next 30 days") already
// sits on the selector button to the right, so repeating it here is noise.
Text(
text = "${agendaRangeLabel(range)}, $window",
text = window,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -289,6 +290,7 @@ private fun AgendaRangeBanner(
private fun AgendaContent(
state: AgendaUiState,
pastDisplay: PastEventDisplay,
showToday: Boolean,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -304,7 +306,7 @@ private fun AgendaContent(
// Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) {
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
@@ -312,6 +314,14 @@ private fun AgendaContent(
} else {
state.days
}
// Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) {
AgendaEmpty(modifier)
} else {
@@ -349,17 +359,24 @@ private fun AgendaList(
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
itemsIndexed(
items = day.events,
key = { _, event -> event.instanceId },
) { index, event ->
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
if (day.events.isEmpty()) {
// An anchored, event-less today (#35) — "nothing left today".
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
}
} else {
itemsIndexed(
items = day.events,
key = { _, event -> event.instanceId },
) { index, event ->
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
@@ -391,6 +408,44 @@ private fun AgendaDayHeader(
}
}
/**
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
* visible slot when nothing is left rather than a bare header.
*/
@Composable
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
Card(
// Match a single event row's resting corner radius (floret groupedShape).
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.agenda_no_more_today),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun AgendaEventRow(
event: EventInstance,
@@ -400,6 +455,7 @@ private fun AgendaEventRow(
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
@@ -412,7 +468,7 @@ private fun AgendaEventRow(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
.background(eventFill(event.color, dark, soften)),
)
},
onClick = onClick,
@@ -520,7 +576,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
return "$weekday, ${date.day}. $monthName ${date.year}"
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
}

View File

@@ -41,6 +41,24 @@ fun groupAgendaDays(
)
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
* grouped by day (only days that actually have events appear).

View File

@@ -66,6 +66,19 @@ class AgendaViewModel @Inject constructor(
initialValue = PastEventDisplay.SHOW,
)
/**
* Whether to keep today anchored at the top with a "nothing left today"
* placeholder even once it has no remaining events (#35). A display concern
* applied in the composition (after past-event filtering), so it rides
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate

View File

@@ -93,6 +93,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
@@ -108,7 +110,6 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
@@ -565,6 +566,7 @@ private fun CalendarEditor(
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Scaffold(
modifier = Modifier
@@ -624,7 +626,7 @@ private fun CalendarEditor(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
InlineTextField(
value = name,
onValueChange = { name = it },

View File

@@ -14,16 +14,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
* visibility filter so they read identically.
* in the calendar's colour — softened to a pastel, or raw when the softener is
* off (issue #36). Shared by the calendar manager and the visibility filter so
* they read identically.
*/
@Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Box(
modifier = modifier
.size(40.dp)
@@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = pastelize(color, dark),
tint = eventFill(color, dark, soften),
modifier = Modifier.size(22.dp),
)
}

View File

@@ -18,15 +18,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/**
* A wrapping row of round colour swatches; the one matching [selected] is
* ringed and checked. Shared by the calendar editor and the event-colour
* picker so both pick a colour the same way. Swatches render through
* [pastelize] — the softened colour the app actually paints, not the raw hue.
* [eventFill] — the colour the app actually paints (softened, or raw when the
* softener is off, issue #36), not necessarily the stored hue.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -37,16 +36,18 @@ fun ColorSwatchRow(
dark: Boolean,
modifier: Modifier = Modifier,
) {
val soften = LocalSoftenColors.current
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
colors.forEach { argb ->
val isSelected = argb == selected
val fill = eventFill(argb, dark, soften)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(vertical = 4.dp)
.size(40.dp)
.clip(CircleShape)
.background(pastelize(argb, dark))
.background(fill)
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
@@ -60,7 +61,7 @@ fun ColorSwatchRow(
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f),
tint = eventInk(fill, alpha = 0.7f),
modifier = Modifier.size(20.dp),
)
}

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import de.jeanlucmakiola.floret.components.pastelize
/**
* Whether calendar/event colours are softened toward theme-fitting pastels
* before display (issue #36). Provided app-wide from the "soften colours"
* setting; the default `true` keeps the historical look. When off, the raw
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
* and other calendar apps. Widgets live outside this composition and read the
* preference directly, then pass the flag to [eventFill] / [eventInk].
*/
val LocalSoftenColors = staticCompositionLocalOf { true }
/**
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
* pastelize also returns an opaque colour).
*/
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
/**
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
* white on a dark fill, near-black on a light one (issue #21). Applies to both
* softened and raw fills — a saturated hue (deep blue, purple, red) is
* perceptually dark even after softening pins its HSV value, so black text on it
* reads poorly. The choice is objective, not a tuned threshold: white wins only
* when it out-contrasts black against the fill, which (by the WCAG contrast
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
* near-black text. [alpha] carries the caller's soft emphasis.
*/
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
}
/**
* Relative-luminance crossover where white text starts to out-contrast black.
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
*/
private const val INK_LUMINANCE_CROSSOVER = 0.179f

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
@@ -18,3 +19,15 @@ fun currentLocale(): Locale {
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
}
}
/**
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
* (via Android's best-pattern matching), so dates read naturally per locale
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
*/
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
DateTimeFormatter.ofPattern(
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
locale,
)

View File

@@ -1,26 +1,14 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -29,13 +17,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
@@ -195,56 +182,18 @@ private fun CustomReminderEditor(
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Unit toggle first so it stays visible above the keyboard once the
// amount field (the bottom row) is focused and scrolled into view.
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
// Amount, a live preview of the lead time it resolves to, and Set —
// all on one row, sitting just above the keyboard.
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "10",
)
Spacer(Modifier.width(16.dp))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = unit.ordinal,
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
)
}
/** A short explanatory paragraph shown under a picker's title, above the rows. */
@@ -364,41 +313,136 @@ private fun CustomDaysEditor(
) {
val days = amountText.toIntOrNull()
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
placeholder = "30",
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
?: stringResource(R.string.agenda_range_custom_hint),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = days != null,
onConfirm = { days?.let(onConfirm) },
)
}
/**
* Snooze-duration picker, full-screen and **single-select**: the [presets]
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
* a preset or confirming a custom value applies via [onSelect] and closes.
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
* is reused for both the rows and the custom preview.
*/
@Composable
fun SnoozeDurationPicker(
title: String,
presets: List<Int>,
selected: Int,
label: @Composable (Int) -> String,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val customSelected = selected !in presets
val rowCount = presets.size + 1 // + the custom row
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
}
var unit by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow(
title = label(minute),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(minute)
onDismiss()
},
)
}
// The Custom row connects downward into the editor card when expanded, so
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "30",
CustomSnoozeEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
label = label,
onConfirm = { minutes ->
onSelect(minutes)
onDismiss()
},
)
Spacer(Modifier.width(16.dp))
Text(
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
?: stringResource(R.string.agenda_range_custom_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { days?.let(onConfirm) },
enabled = days != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
/** Whole hours if the delay divides evenly, else minutes. */
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
private fun snoozeCustomAmount(minutes: Int): Int =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
/**
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
* the delay it resolves to, and a tonal confirm enabled only for a valid 1999
* amount. [onConfirm] receives the final delay in minutes.
*/
@Composable
private fun CustomSnoozeEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
label: @Composable (Int) -> String,
onConfirm: (Int) -> Unit,
) {
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
onUnitChange = { onUnitChange(units[it]) },
preview = amount?.let { label(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
)
}
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
@Composable
fun agendaRangeLabel(range: AgendaRange): String = when (range) {

View File

@@ -79,7 +79,9 @@ import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -97,6 +99,10 @@ import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's hour labels so they centre on the top bar's
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
* 4dp inset + 24dp half icon button), matching the week view. */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -448,9 +454,11 @@ private fun AllDayBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
@@ -461,7 +469,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
color = eventInk(fill),
)
}
}
@@ -484,10 +492,12 @@ private fun Timeline(
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day column)
// Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
@@ -610,9 +620,11 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box(
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
@@ -623,7 +635,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
color = eventInk(fill, alpha = 0.85f),
)
if (showTime) {
Text(
@@ -631,7 +643,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
color = eventInk(fill, alpha = 0.6f),
)
}
}

View File

@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications
@@ -87,17 +88,19 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
@@ -116,7 +119,10 @@ import kotlin.time.Instant
* top-bar arrow both return to the calendar. Events in writable calendars can
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
* event form for this occurrence — for recurring events the form asks how
* far the change reaches when saving.
* far the change reaches when saving. [onDuplicate] opens the same form
* seeded with a copy of this event as a new, unsaved event (#52) — offered
* for any event, including read-only ones, since the copy lands in an
* editable calendar.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -126,6 +132,7 @@ fun EventDetailScreen(
endMillis: Long,
onBack: () -> Unit,
onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -160,15 +167,14 @@ fun EventDetailScreen(
}
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action.
var pendingEdit by remember { mutableStateOf(false) }
// upgrade in place. Granting continues straight into the tapped action
// edit, delete, or duplicate — held here until the result comes back.
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
if (pendingEdit) onEdit() else showDeleteDialog = true
}
pendingEdit = false
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
@@ -176,21 +182,20 @@ fun EventDetailScreen(
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
}
val onDeleteClick = {
val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) {
showDeleteDialog = true
action()
} else {
pendingEdit = false
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val onEditClick = {
if (hasWritePermission()) {
onEdit()
} else {
pendingEdit = true
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the
// create still needs WRITE_CALENDAR even when the source is read-only.
val onDuplicateClick = {
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
}
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
@@ -229,7 +234,8 @@ fun EventDetailScreen(
},
actions = {
val s = state
// Share works for any loaded event — it only reads the event.
// Share and duplicate work for any loaded event — both only
// read it; the duplicate is created into a writable calendar.
if (s is EventDetailUiState.Success) {
IconButton(onClick = onShareClick) {
Icon(
@@ -237,6 +243,15 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_share),
)
}
IconButton(
onClick = onDuplicateClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.event_detail_duplicate),
)
}
}
// Edit/delete need a writable calendar — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level.
@@ -367,7 +382,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance
val dark = isSystemInDarkTheme()
val locale = currentDetailLocale()
val accent = pastelize(instance.color, dark)
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
Column(
modifier = modifier

View File

@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlin.time.Clock
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
@@ -138,16 +141,48 @@ class EventDetailViewModel @Inject constructor(
}.getOrNull()
}
/**
* Build a create form seeded from the open event so the user can save a
* copy as a new, independent event (#52). Returns null when nothing is
* loaded.
*
* The recurrence is dropped — a duplicate is a single event; anyone who
* wants a series can re-add one in the edit form that opens. The source
* calendar is kept only when it's writable, otherwise the id is cleared so
* the create resolves to the last-used/first-writable calendar — which lets
* a read-only event (a WebCal subscription, a birthday) be copied into an
* editable calendar. The occurrence's own times carry over unchanged.
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail
return detail.toEditForm(
beginMillis = detail.instance.start.toEpochMilliseconds(),
endMillis = detail.instance.end.toEpochMilliseconds(),
zone = TimeZone.currentSystemDefault(),
).copy(
rrule = null,
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
)
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
// occurrence's time so recurring events render correctly.
val corrected = detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
// occurrence's time so recurring events render correctly. An external
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
// own DTSTART/DTEND instead of overriding it to the epoch.
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
detail
} else {
detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
}
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
EventDetailUiState.Success(
@@ -168,6 +203,16 @@ class EventDetailViewModel @Inject constructor(
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
companion object {
/**
* Sentinel begin/end for an "open this event" that names no occurrence —
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
* row's own DTSTART/DTEND instead of overriding it to the epoch.
*/
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
}
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */

View File

@@ -89,6 +89,7 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
@@ -125,6 +126,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
@@ -134,6 +137,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
@@ -144,7 +148,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -181,12 +184,14 @@ fun EventEditScreen(
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
when {
// Single-event .ics open: the form arrives prefilled for review.
initialForm != null -> viewModel.openImported(initialForm)
// A prefilled open: a single-event .ics for review, or an external
// ACTION_INSERT intent. The source drives how reminders are seeded.
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
editKey != null -> viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
@@ -201,6 +206,7 @@ fun EventEditScreen(
}
val state by viewModel.state.collectAsStateWithLifecycle()
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
// The form is intentionally forgotten on every close (cancel or save) so
// the next open starts clean; it survives rotation because openNew /
@@ -331,6 +337,56 @@ fun EventEditScreen(
},
)
}
// A .ics import respects the file's reminders, but offers to swap in the
// configured default rather than silently deciding for the user (#49).
importReminderPrompt?.let { prompt ->
ImportReminderPromptDialog(
currentReminderCount = prompt.currentReminderCount,
onApply = viewModel::applyImportedReminderDefault,
onKeep = viewModel::dismissImportedReminderPrompt,
)
}
}
/**
* Offer to apply the settings default reminder to an event opened from a `.ics`
* file. The file's own reminders are kept unless the user accepts. A plain
* two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
*/
@Composable
private fun ImportReminderPromptDialog(
currentReminderCount: Int,
onApply: () -> Unit,
onKeep: () -> Unit,
) {
AlertDialog(
onDismissRequest = onKeep,
title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
text = {
Text(
if (currentReminderCount == 0) {
stringResource(R.string.import_reminder_prompt_body_none)
} else {
pluralStringResource(
R.plurals.import_reminder_prompt_body_existing,
currentReminderCount,
currentReminderCount,
)
},
)
},
confirmButton = {
TextButton(onClick = onApply) {
Text(stringResource(R.string.import_reminder_prompt_apply))
}
},
dismissButton = {
TextButton(onClick = onKeep) {
Text(stringResource(R.string.import_reminder_prompt_keep))
}
},
)
}
/**
@@ -345,29 +401,29 @@ private fun SaveConflictDialog(
onDiscard: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.event_edit_conflict_body))
Spacer(Modifier.height(4.dp))
OptionCard(
label = stringResource(R.string.event_edit_conflict_overwrite),
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
onClick = onOverwrite,
)
OptionCard(
label = stringResource(R.string.event_edit_conflict_discard),
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
onClick = onDiscard,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
FullScreenPicker(
title = stringResource(R.string.event_edit_conflict_title),
onDismiss = onDismiss,
) {
Text(
text = stringResource(R.string.event_edit_conflict_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
GroupedRow(
title = stringResource(R.string.event_edit_conflict_overwrite),
summary = stringResource(R.string.event_edit_conflict_overwrite_hint),
position = positionOf(0, 2),
onClick = onOverwrite,
)
GroupedRow(
title = stringResource(R.string.event_edit_conflict_discard),
summary = stringResource(R.string.event_edit_conflict_discard_hint),
position = positionOf(1, 2),
onClick = onDiscard,
)
}
}
/**
@@ -486,7 +542,8 @@ private fun EventEditContent(
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour.
val accent = selectedCalendar?.let { pastelize(it.color, dark) }
val soften = LocalSoftenColors.current
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
?: MaterialTheme.colorScheme.primary
val gap = 12.dp
@@ -617,15 +674,16 @@ private fun EventEditContent(
Spacer(Modifier.height(gap))
// Calendar card — tap anywhere to pick the target calendar. Editing
// keeps the owning calendar (moving events between calendars is a
// sync-adapter minefield; every stock calendar app locks it too).
// Calendar card — tap anywhere to pick the target calendar. Picking a
// different one while editing *moves* the event (copy+delete on save,
// since CALENDAR_ID can't be updated in place). Managed special-dates
// events stay locked: the contact sync owns their calendar.
EditCard(
icon = Icons.Default.CalendarMonth,
iconContentDescription = stringResource(R.string.event_detail_calendar),
iconTint = accent,
onClick = { showCalendarPicker = true }
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
) {
Text(
text = selectedCalendar?.displayName
@@ -837,7 +895,7 @@ private fun EventEditContent(
icon = Icons.Default.Palette,
iconContentDescription = stringResource(R.string.event_edit_color),
iconTint = if (colorSupported && swatch != null) {
pastelize(swatch, dark)
eventFill(swatch, dark, soften)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
@@ -1064,24 +1122,19 @@ private fun FieldPickerDialog(
onSelect: (EventFormField) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_more_fields)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
hiddenFields.forEach { field ->
OptionCard(
label = stringResource(eventFormFieldLabel(field)),
onClick = { onSelect(field) },
icon = eventFormFieldIcon(field),
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
FullScreenPicker(
title = stringResource(R.string.event_edit_more_fields),
onDismiss = onDismiss,
) {
hiddenFields.forEachIndexed { index, field ->
GroupedRow(
title = stringResource(eventFormFieldLabel(field)),
position = positionOf(index, hiddenFields.size),
leading = { Icon(imageVector = eventFormFieldIcon(field), contentDescription = null) },
onClick = { onSelect(field) },
)
}
}
}
/** Quick-pick lead times offered as chips in the reminder dialog. */
@@ -1106,41 +1159,10 @@ private fun ReminderPickerDialog(
?.takeIf { it in 1..999 }
?.let { it * unit.minutesFactor }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_add_reminder)) },
text = {
if (!customMode) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }.forEach { minutes ->
OptionCard(
label = reminderLabel(minutes),
onClick = { onSelect(minutes) },
)
}
OptionCard(
label = stringResource(R.string.event_edit_reminder_custom),
onClick = { customMode = true },
labelColor = MaterialTheme.colorScheme.primary,
)
}
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = amountText,
onValueChange = { amountText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(reminderUnitLabel(unit)),
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
onPick = { unit = ReminderUnit.entries[it] },
)
}
}
},
confirmButton = {
FullScreenPicker(
title = stringResource(R.string.event_edit_add_reminder),
onDismiss = onDismiss,
actions = {
// The quick-pick list adds on tap; only the custom step needs Add.
if (customMode) {
TextButton(
@@ -1149,10 +1171,41 @@ private fun ReminderPickerDialog(
) { Text(stringResource(R.string.event_edit_add)) }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
) {
if (!customMode) {
val presets = REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }
val rowCount = presets.size + 1
presets.forEachIndexed { index, minutes ->
GroupedRow(
title = reminderLabel(minutes),
position = positionOf(index, rowCount),
onClick = { onSelect(minutes) },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rowCount - 1, rowCount),
onClick = { customMode = true },
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
) {
DialogAmountField(
value = amountText,
onValueChange = { amountText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(reminderUnitLabel(unit)),
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
onPick = { unit = ReminderUnit.entries[it] },
)
}
}
}
}
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
@@ -1222,109 +1275,10 @@ private fun RecurrencePickerDialog(
null
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_detail_recurrence)) },
text = {
if (!customMode) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_edit_recurrence_none),
onClick = { onSelect(null) },
selected = current == null,
)
RecurrenceFreq.entries.forEach { entry ->
OptionCard(
label = stringResource(recurrencePresetLabel(entry)),
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
selected = isPlainPreset && parsed?.freq == entry,
)
}
OptionCard(
label = stringResource(R.string.event_edit_recurrence_custom),
onClick = { customMode = true },
selected = current != null && !isPlainPreset,
labelColor = MaterialTheme.colorScheme.primary,
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.event_edit_recurrence_every),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(12.dp))
DialogAmountField(
value = intervalText,
onValueChange = { intervalText = it },
placeholder = "1",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(recurrenceUnitLabel(freq)),
entries = RecurrenceFreq.entries.map {
stringResource(recurrenceUnitLabel(it))
},
onPick = { freq = RecurrenceFreq.entries[it] },
)
}
if (freq == RecurrenceFreq.Weekly) {
Spacer(Modifier.height(4.dp))
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_never),
onClick = { endMode = RecurrenceEndMode.Never },
selected = endMode == RecurrenceEndMode.Never,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_until),
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
supportingText = untilDate?.let {
remember(it, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
selected = endMode == RecurrenceEndMode.Until,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_count),
onClick = { endMode = RecurrenceEndMode.Count },
selected = endMode == RecurrenceEndMode.Count,
)
if (endMode == RecurrenceEndMode.Count) {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = countText,
onValueChange = { countText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_times),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
},
confirmButton = {
FullScreenPicker(
title = stringResource(R.string.event_detail_recurrence),
onDismiss = onDismiss,
actions = {
// The preset list applies on tap; only the custom step needs OK.
if (customMode) {
TextButton(
@@ -1333,10 +1287,113 @@ private fun RecurrencePickerDialog(
) { Text(stringResource(R.string.dialog_ok)) }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
) {
if (!customMode) {
val rowCount = RecurrenceFreq.entries.size + 2
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_none),
position = positionOf(0, rowCount),
selected = current == null,
onClick = { onSelect(null) },
)
RecurrenceFreq.entries.forEachIndexed { index, entry ->
GroupedRow(
title = stringResource(recurrencePresetLabel(entry)),
position = positionOf(index + 1, rowCount),
selected = isPlainPreset && parsed?.freq == entry,
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_custom),
position = positionOf(rowCount - 1, rowCount),
selected = current != null && !isPlainPreset,
onClick = { customMode = true },
)
} else {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 24.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.event_edit_recurrence_every),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(12.dp))
DialogAmountField(
value = intervalText,
onValueChange = { intervalText = it },
placeholder = "1",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(recurrenceUnitLabel(freq)),
entries = RecurrenceFreq.entries.map {
stringResource(recurrenceUnitLabel(it))
},
onPick = { freq = RecurrenceFreq.entries[it] },
)
}
if (freq == RecurrenceFreq.Weekly) {
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
)
}
Text(
text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_never),
position = positionOf(0, 3),
selected = endMode == RecurrenceEndMode.Never,
onClick = { endMode = RecurrenceEndMode.Never },
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_until),
summary = untilDate?.let {
remember(it, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
position = positionOf(1, 3),
selected = endMode == RecurrenceEndMode.Until,
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_count),
position = positionOf(2, 3),
selected = endMode == RecurrenceEndMode.Count,
onClick = { endMode = RecurrenceEndMode.Count },
)
if (endMode == RecurrenceEndMode.Count) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
) {
DialogAmountField(
value = countText,
onValueChange = { countText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_times),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
if (showUntilPicker) {
CalendarDatePickerDialog(
@@ -1701,24 +1758,14 @@ private fun VisibilityPickerDialog(
onSelect: (AccessLevel) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_visibility)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
AccessLevel.entries.forEach { level ->
OptionCard(
label = stringResource(accessLevelLabel(level)),
onClick = { onSelect(level) },
icon = accessLevelIcon(level),
selected = level == selected,
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
OptionPicker(
title = stringResource(R.string.event_edit_visibility),
options = AccessLevel.entries.toList(),
selected = selected,
label = { stringResource(accessLevelLabel(it)) },
onSelect = onSelect,
onDismiss = onDismiss,
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
)
}
@@ -1741,51 +1788,57 @@ private fun ColorPickerDialog(
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_color)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (palette.isNotEmpty()) {
ColorSwatchRow(
colors = palette.map { it.argb },
selected = selected,
onSelect = { argb ->
palette.firstOrNull { it.argb == argb }
?.let { onPickKey(it.key, it.argb) }
},
dark = dark,
)
} else {
ColorSwatchRow(
colors = CalendarColorPalette.all,
selected = selected,
onSelect = onPickRaw,
dark = dark,
)
if (syncWarning) {
Text(
text = stringResource(R.string.event_edit_color_sync_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
dismissButton = if (hasExplicitColor) {
{
FullScreenPicker(
title = stringResource(R.string.event_edit_color),
onDismiss = onDismiss,
actions = {
if (hasExplicitColor) {
TextButton(onClick = onClear) {
Text(stringResource(R.string.event_edit_color_reset))
}
}
} else {
null
},
)
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
) {
if (palette.isNotEmpty()) {
// The event's current colour may not be in the curated palette
// (a thinned near-duplicate, or a raw colour set elsewhere) —
// append it so the selection ring has a home.
val swatches = palette.map { it.argb }.let {
if (selected != null && selected !in it) it + selected else it
}
ColorSwatchRow(
colors = swatches,
selected = selected,
onSelect = { argb ->
val option = palette.firstOrNull { it.argb == argb }
// The appended current colour has no provider key to
// write — it is already the event's colour, so just
// close.
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
},
dark = dark,
)
} else {
ColorSwatchRow(
colors = CalendarColorPalette.all,
selected = selected,
onSelect = onPickRaw,
dark = dark,
)
if (syncWarning) {
Text(
text = stringResource(R.string.event_edit_color_sync_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {

View File

@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -50,6 +51,36 @@ import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import javax.inject.Inject
/**
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
* sources want different reminder handling (#49).
*/
enum class ImportSource {
/**
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
* It carries no reminders of its own, so the settings default is applied
* automatically, exactly like an in-app new event.
*/
Insert,
/**
* A parsed single-event `.ics` file. Its own reminders are respected; the
* settings default is offered through [EventEditViewModel.importReminderPrompt]
* rather than silently applied or suppressed.
*/
File,
}
/**
* A pending offer to swap an imported `.ics` event's reminders for the settings
* default (#49). [currentReminderCount] is what the file carried (0 or more);
* [defaultReminders] is what accepting would set.
*/
data class ImportReminderPrompt(
val currentReminderCount: Int,
val defaultReminders: List<Int>,
)
/**
* Holds the event form being composed. The form's calendar id resolves to
* (user pick > last used > first writable); the resolved value is what the UI
@@ -78,10 +109,16 @@ class EventEditViewModel @Inject constructor(
// freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false)
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
/**
* The event being edited plus everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the
@@ -112,7 +149,17 @@ class EventEditViewModel @Inject constructor(
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
)
) {
/** The default reminders for an event on [calendarId] of the given kind. */
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
timedGlobal = timed,
allDayGlobal = allDay,
timedOverrides = timedOverrides,
allDayOverrides = allDayOverrides,
calendarId = calendarId,
isAllDay = isAllDay,
)
}
private data class ExternalInputs(
val writable: List<CalendarSource>,
@@ -241,18 +288,39 @@ class EventEditViewModel @Inject constructor(
}
/**
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
* the create form" path). [form] already carries the file's fields; its
* [EventForm.calendarId] is null so the calendar still resolves to the
* last-used/first-writable one, and reminders are frozen as touched so the
* settings default never overwrites what the file specified. No-op when a
* form is already open, so the prefill survives configuration changes.
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
* app/widget creating an event, e.g. Google Maps' "add to calendar";
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
* calendar still resolves to the last-used/first-writable one.
*
* Reminders are handled per [source], because the two paths mean different
* things by "no reminders":
* - [ImportSource.Insert] carries no reminder semantics, so the settings
* default is applied automatically like an in-app new event (a form that
* somehow did carry reminders keeps them, frozen).
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
* settings default is configured and differs, [importReminderPrompt] offers
* to swap it in rather than silently deciding for the user.
*
* No-op when a form is already open, so the prefill survives configuration
* changes.
*/
fun openImported(form: EventForm) {
fun openImported(form: EventForm, source: ImportSource) {
if (_form.value != null || _editTarget.value != null) return
_remindersTouched.value = true
_revealed.value = form.populatedFields()
_form.value = form
when (source) {
ImportSource.Insert ->
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
else applyDefaultReminder()
ImportSource.File -> {
// Respect the file's own reminders; never silently overwrite them.
_remindersTouched.value = true
maybePromptImportedReminderDefault(form)
}
}
}
/**
@@ -266,26 +334,12 @@ class EventEditViewModel @Inject constructor(
private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return
viewModelScope.launch {
val defaults = combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
val defaults = reminderDefaults()
val targetId = calendarId ?: resolvedCalendarId.first()
// Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = resolveDefaultReminder(
timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides,
allDayOverrides = defaults.allDayOverrides,
calendarId = targetId,
isAllDay = form.isAllDay,
)
val reminders = defaults.resolveFor(targetId, form.isAllDay)
_form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
@@ -295,10 +349,60 @@ class EventEditViewModel @Inject constructor(
}
}
/** Snapshot the four settings-default reminder flows into one value. */
private suspend fun reminderDefaults(): ReminderDefaults = combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
/**
* A `.ics` import respects the file's reminders, but an event opened from a
* file often has none while the user still expects their configured default.
* Rather than silently deciding, raise a one-time offer to swap in the
* settings default — but only when there's a real choice: a default is
* configured and it isn't already exactly what the file carried.
*/
private fun maybePromptImportedReminderDefault(form: EventForm) {
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
// Bail if the form closed or became an edit while we resolved.
val current = _form.value ?: return@launch
if (_editTarget.value != null) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
_importReminderPrompt.value = ImportReminderPrompt(
currentReminderCount = current.reminders.size,
defaultReminders = default,
)
}
}
/** Accept the import prompt: replace the file's reminders with the default. */
fun applyImportedReminderDefault() {
val prompt = _importReminderPrompt.value ?: return
_importReminderPrompt.value = null
// Already frozen as touched by openImported; this just swaps the values.
update { it.copy(reminders = prompt.defaultReminders) }
_revealed.value = _revealed.value + EventFormField.Reminders
}
/** Decline the import prompt: keep the file's own reminders untouched. */
fun dismissImportedReminderPrompt() {
_importReminderPrompt.value = null
}
/**
* Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. No-op while a
* form is open, so user edits survive configuration changes.
* tapped occurrence's own times, like on the detail screen. An external
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
* form loads the event's real times instead of the epoch (mirrors the
* detail screen's #48 fallback). No-op while a form is open, so user edits
* survive configuration changes.
*/
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return
@@ -312,8 +416,12 @@ class EventEditViewModel @Inject constructor(
return@launch
}
val zone = TimeZone.currentSystemDefault()
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
@@ -329,6 +437,7 @@ class EventEditViewModel @Inject constructor(
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
_importReminderPrompt.value = null
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -439,10 +548,15 @@ class EventEditViewModel @Inject constructor(
_saveState.value = SaveUiState.Saved
return
}
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
// be updated in place) and is inherently whole-series: an occurrence
// can't live in a different calendar than its series, so a move skips the
// scope dialog even for a recurring event.
val movingCalendar = target != null && form.calendarId != target.original.calendarId
// 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) {
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
_saveState.value = SaveUiState.AwaitingScope
return
}
@@ -497,6 +611,17 @@ class EventEditViewModel @Inject constructor(
if (target == null) {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
} else if (form.calendarId != target.original.calendarId) {
// Move to the picked calendar (copy+delete), carrying any
// field edits made in the same save. The target becomes the
// last-used calendar, like a create does.
repository.moveEvent(
eventId = target.eventId,
targetCalendarId = requireNotNull(form.calendarId),
original = target.original,
updated = form,
)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
} else {
when (scope) {
RecurringWriteScope.ThisEvent ->

View File

@@ -71,6 +71,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -79,7 +82,6 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek
@@ -380,7 +382,10 @@ private fun MonthGrid(
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 4.dp, vertical = 4.dp),
// Match the weekday header's 8dp inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
state.weeks.forEach { week ->
@@ -627,7 +632,7 @@ private fun DayNumberCell(
}
}
/** A filled event pill/bar — pastelized fill, title clipped to one line. */
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
@Composable
private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance,
@@ -639,6 +644,8 @@ private fun MonthBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -647,7 +654,7 @@ private fun MonthBar(
)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), shape)
.background(fill, shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
@@ -657,7 +664,7 @@ private fun MonthBar(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
color = eventInk(fill),
)
}
}
@@ -670,6 +677,7 @@ private fun OverflowDots(
dark: Boolean,
modifier: Modifier = Modifier,
) {
val soften = LocalSoftenColors.current
Row(
modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp),
@@ -679,7 +687,7 @@ private fun OverflowDots(
Box(
modifier = Modifier
.size(6.dp)
.background(pastelize(argb, dark), CircleShape),
.background(eventFill(argb, dark, soften), CircleShape),
)
}
if (extra > 0) {

View File

@@ -55,9 +55,10 @@ import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import java.time.Instant as JavaInstant
import java.time.ZoneId
@@ -192,6 +193,7 @@ private fun SearchResultRow(
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedRow(
modifier = modifier,
title = event.title,
@@ -203,7 +205,7 @@ private fun SearchResultRow(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
.background(eventFill(event.color, dark, soften)),
)
},
onClick = onClick,

View File

@@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
@@ -160,7 +161,7 @@ private enum class ChipAccent { Neutral, Primary, Tertiary }
* Settings (M4), restructured in v2.3 into a category hub with sub-screens.
* Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the
* grouped-row card system. Calendars opens the separate manager hoisted in
* [CalendarHost]; Language opens an inline OptionCard dialog; About is a card
* [CalendarHost]; Language opens a full-screen picker; About is a card
* at the top. A full-screen destination; [onBack] pops it.
*/
@Composable
@@ -516,7 +517,7 @@ private fun AppearanceScreen(
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Bottom,
position = Position.Middle,
trailing = {
Switch(
checked = state.dynamicColor,
@@ -530,6 +531,18 @@ private fun AppearanceScreen(
null
},
)
GroupedRow(
title = stringResource(R.string.settings_soften_colors),
summary = stringResource(R.string.settings_soften_colors_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.softenColors,
onCheckedChange = viewModel::setSoftenColors,
)
},
onClick = { viewModel.setSoftenColors(!state.softenColors) },
)
Spacer(Modifier.height(16.dp))
@@ -630,6 +643,18 @@ private fun AppearanceScreen(
position = Position.Middle,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_show_today),
summary = stringResource(R.string.settings_agenda_show_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
)
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
@@ -1174,10 +1199,9 @@ private fun NotificationsScreen(
}
if (showSnooze) {
OptionPicker(
SnoozeDurationPicker(
title = stringResource(R.string.settings_snooze_duration),
predictiveBack = true,
options = SNOOZE_PRESETS,
presets = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },

View File

@@ -24,6 +24,8 @@ data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
/** Whether raw provider colours are softened to theme-fitting pastels (#36). */
val softenColors: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
@@ -39,6 +41,8 @@ data class SettingsUiState(
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */

View File

@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
@@ -116,14 +117,23 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
// Two grid-display toggles folded into one flow so they fit this
// group — the outer combine is already at its five-arg limit.
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
// Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit.
combine(
prefs.showHourLines,
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
) { hourLines, weekNumbers, showToday, soften ->
DisplayToggles(hourLines, weekNumbers, showToday, soften)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = gridToggles.first,
showWeekNumbers = gridToggles.second,
showHourLines = toggles.showHourLines,
showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
)
},
combine(
@@ -147,6 +157,8 @@ class SettingsViewModel @Inject constructor(
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -220,6 +232,15 @@ class SettingsViewModel @Inject constructor(
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
)
private data class DisplayToggles(
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
)
private data class MiscSettings(
@@ -332,6 +353,19 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDynamicColor(enabled) }
}
fun setSoftenColors(enabled: Boolean) {
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener, so a
// change has to redraw them too (they read the flag in their data
// preamble, like is24Hour).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setFont(role: FontRole, token: String) {
viewModelScope.launch {
when (role) {
@@ -398,6 +432,22 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// The agenda widget reads this reactively, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) }
}

View File

@@ -84,6 +84,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -98,7 +101,6 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -114,6 +116,10 @@ import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's content (week badge + hour labels) so it centres
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
* (the app bar's 4dp inset + 24dp half icon button). */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -448,9 +454,10 @@ private fun WeekDayHeader(
.padding(top = 4.dp, bottom = 8.dp),
) {
// Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers.
// badge lines up vertically with the date numbers. The start inset centres
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
Column(
modifier = Modifier.width(GUTTER_WIDTH),
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
@@ -584,9 +591,11 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
@@ -597,7 +606,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
color = eventInk(fill),
)
}
}
@@ -621,10 +630,12 @@ private fun Timeline(
// soft corners are permanent at any scroll position (not just at the
// day's start/end).
Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day columns)
// Hour gutter (scrolls in sync with the day columns). Same start inset
// as the header badge so the labels sit under it and on the hamburger.
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
@@ -774,9 +785,11 @@ private fun EventBlock(
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
@@ -787,7 +800,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
color = eventInk(fill, alpha = 0.85f),
)
if (showTime) {
Text(
@@ -795,7 +808,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
color = eventInk(fill, alpha = 0.6f),
)
}
}

View File

@@ -60,12 +60,20 @@ sealed interface AgendaWidgetData {
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/**
* Saved "always show today" pref (#35) — anchors today at the top with a
* placeholder even when it has no events left. The fallback read reactively
* from Glance state before an instance has its own state set.
*/
val savedShowToday: Boolean,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
@@ -117,6 +125,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain
@@ -125,13 +134,16 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, instances, zone),
is24Hour = is24Hour,
soften = soften,
weekStart = weekStart,
savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
now = Clock.System.now(),
)
}

View File

@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
@@ -48,12 +49,14 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
@@ -65,7 +68,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/**
@@ -91,6 +93,14 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
*/
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
/**
* Per-instance Glance state key holding the "always show today" toggle (#35).
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
* so toggling the setting reflects on the live widget without depending on the
* `provideGlance` preamble re-running.
*/
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
@@ -118,6 +128,8 @@ class RefreshAgendaAction : ActionCallback {
private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */
data class Placeholder(val date: LocalDate) : AgendaRow
}
@Composable
@@ -139,40 +151,53 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
val visibleDays = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(data.now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
// Finally re-anchor today (as an empty placeholder day) when the
// "always show today" pref is on and today has no events left (#35).
val visibleDays = anchorTodayIfMissing(
days = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(data.now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
days
}
} else {
days
}
}
},
today = data.today,
enabled = showToday,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
day.events.forEach { add(AgendaRow.Event(it)) }
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(it)) }
}
}
}
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
soften = data.soften,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
@@ -262,13 +287,41 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
)
}
/**
* "No more events today" under an anchored, event-less today (#35). Kept plain —
* a muted line indented to the event titles — since the widget's event rows are
* plain too (a stripe + text, not cards), so a card here would look out of place.
*/
@Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
private fun PlaceholderRow(date: LocalDate) {
val context = androidx.glance.LocalContext.current
Text(
text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@Composable
private fun EventRow(
event: EventInstance,
dark: Boolean,
soften: Boolean,
is24Hour: Boolean,
dimmed: Boolean,
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = pastelize(event.color, dark).let {
val stripeColor = eventFill(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
@@ -337,9 +390,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
}
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
val formatted = "$weekday, ${date.day} $monthName"
// Weekday + date in the locale's own field order (compact: no year), rather
// than a hardcoded day-month layout.
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
return if (relative != null) "$relative · $formatted" else formatted
}

View File

@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.month
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.intPreferencesKey
@@ -50,14 +49,17 @@ import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -76,9 +78,6 @@ private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
private val EventInk = ColorProvider(Color(0xDE000000))
private fun currentMonthIndex(zone: TimeZone): Int {
val t = today(zone)
return t.year * 12 + t.month.ordinal
@@ -107,9 +106,12 @@ class MonthWidget : GlanceAppWidget() {
val source = context.loadMonthWidgetSource()
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
// Read fresh (not through the cached source) so toggling the softener
// redraws with the new choice; it's one cheap DataStore read.
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
provideContent {
CalendulaGlanceTheme {
MonthWidgetBody(source = source, dark = dark)
MonthWidgetBody(source = source, dark = dark, soften = soften)
}
}
}
@@ -140,7 +142,7 @@ class ResetMonthAction : ActionCallback {
}
@Composable
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
Column(
modifier = GlanceModifier
.fillMaxSize()
@@ -169,6 +171,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
currentMonth = ym.month,
today = source.today,
dark = dark,
soften = soften,
colW = colW,
modifier = GlanceModifier.defaultWeight(),
)
@@ -278,6 +281,7 @@ private fun WeekRow(
currentMonth: Month,
today: LocalDate,
dark: Boolean,
soften: Boolean,
colW: Dp,
modifier: GlanceModifier,
) {
@@ -297,7 +301,7 @@ private fun WeekRow(
// One lane row per event row. A multi-day span is a single Box spanning
// its columns (colW * n) so it's connected with no seam and rounded ends.
repeat(MAX_LANES) { lane ->
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp))
}
OverflowRow(week = week, colW = colW)
@@ -346,7 +350,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
}
@Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0
@@ -354,12 +358,12 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
if (span != null) {
val cols = span.endCol - col + 1
SpanBar(event = span.event, dark = dark, width = colW * cols)
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
col = span.endCol + 1
} else {
val timed = timedEventAt(week, lane, col, week.days[col])
if (timed != null) {
SpanBar(event = timed, dark = dark, width = colW)
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
} else {
// Empty lane cell: a tap opens that day, so blank space in a
// day column is a day-open target just like the number is.
@@ -378,8 +382,9 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
/** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
val context = LocalContext.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = GlanceModifier
.width(width)
@@ -402,13 +407,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(4.dp)
.background(pastelize(event.color, dark)),
.background(fill),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = EventInk, fontSize = 9.sp),
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
modifier = GlanceModifier.padding(horizontal = 3.dp),
)
}

View File

@@ -220,7 +220,7 @@
<string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string>
<string name="search_back">Zurück</string>
<string name="search_clear">Löschen</string>
<string name="search_clear">Leeren</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets -->
@@ -310,7 +310,7 @@
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
<string name="settings_section_about">Über</string>
@@ -330,7 +330,7 @@
<string name="calendars_add">Kalender hinzufügen</string>
<string name="calendars_synced_header">Synchronisierte Kalender</string>
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
<string name="calendars_manage_in_app">Verwalten</string>
<string name="calendars_manage_in_app">In der App verwalten</string>
<string name="calendars_add_account">Konto hinzufügen</string>
<string name="calendars_new_title">Neuer Kalender</string>
<string name="calendars_edit_title">Kalender bearbeiten</string>
@@ -394,10 +394,10 @@
<item quantity="other">%d Tage</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">Woche</item>
<item quantity="other">Wochen</item>
<item quantity="one">%d Woche</item>
<item quantity="other">%d Wochen</item>
</plurals>
<string name="settings_default_view">Standartansicht</string>
<string name="settings_default_view">Standardansicht</string>
<string name="settings_font_headings">Überschriften Schriftart</string>
<string name="settings_font_system">Systemstandard</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
@@ -411,4 +411,83 @@
<string name="settings_past_events_hide">Ausblenden</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_special_dates_reminders">Erinnerungen</string>
<string name="settings_font_body">Inhaltsschriftart</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="settings_font_custom_selected">Benutzerdefinierte Schriftart</string>
<string name="settings_section_views">Ansichten</string>
<string name="settings_quick_switch_header">Schnellwechselknopf</string>
<string name="settings_quick_switch_hint">Wähle aus, durch welche Ansichten du mit dem Knopf oben rechts wechseln möchtest, und ziehe, um sie neu anzuordnen. Deaktivierte Ansichten bleiben über das Navigationsmenü erreichbar.</string>
<string name="settings_drawer_order_header">Navigationsmenü</string>
<string name="settings_drawer_order_hint">Ziehen, um die im Navigationsmenü aufgelisteten Ansichten neu anzuordnen.</string>
<string name="reorder_drag_handle">Zum Neuanordnen ziehen</string>
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
<string name="settings_translate">Hilf beim Übersetzen</string>
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
<string name="settings_views_subtitle">Schnellwechsellnopf und Menüreihenfolge</string>
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
<string name="settings_special_dates_enable_hint">Spiegle die Geburtstage und andere Termine deiner Kontakte in lokale Kalender. Liest nur Kontakte auf diesem Gerät es wird nichts hochgeladen und Ihre Kontakte werden nie geändert.</string>
<string name="settings_special_dates_type_birthday">Geburtstage</string>
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
<string name="settings_special_dates_type_custom">Andere Ereignisse</string>
<string name="settings_special_dates_template">Titelformat</string>
<string name="settings_special_dates_template_hint">Verwende {Name} für den Kontakt und {Jahr} für das Jahr (das Geburtsjahr oder das Anfangsjahr eines Jahrestages; versteckt, wenn unbekannt).</string>
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
<string name="settings_special_dates_show_year_hint">{year} in Titel einfügen, wenn bekannt</string>
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
<string name="settings_special_dates_calendar_hint">Lege die Farbe und Sichtbarkeit jedes Kalenders in den Kalendereinstellungen fest.</string>
<string name="settings_special_dates_paused_title">Pausiert</string>
<string name="settings_calendar_reminders_managed_hint">In den besonderen Terminen der Kontakte setzen</string>
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
<string name="settings_special_dates_disable_title">Kontakttermine deaktivieren?</string>
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontaktterminkalender und ihre Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
<string name="settings_special_dates_grant">Zugriff gewähren</string>
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
<string name="dialog_save">Speichern</string>
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
<string name="calendars_enable_all">Alle aktivieren</string>
<string name="calendars_disable_all">Alle deaktivieren</string>
<string name="calendars_auto_backup">Automatische Sicherung</string>
<string name="calendars_auto_backup_hint">Exportiere deine lokalen Kalender regelmäßig als .ics-Datei in einen Ordner.</string>
<string name="calendars_auto_backup_folder">Sicherungsordner</string>
<string name="calendars_auto_backup_folder_unset">Tippe, um einen Ordner auszuwählen</string>
<string name="calendars_auto_backup_interval">Intervall</string>
<string name="calendars_auto_backup_every">Alle %1$s</string>
<string name="calendars_auto_backup_interval_min">Mindestens 30 Minuten.</string>
<string name="calendars_auto_backup_status_never">Noch keine automatische Sicherung</string>
<string name="calendars_auto_backup_status_ok">Letzte Sicherung: %1$s</string>
<string name="calendars_auto_backup_status_failed">Letzte Sicherung fehlgeschlagen: %1$s</string>
<string name="backup_channel_name">Sicherung</string>
<string name="backup_channel_description">Warnt, wenn automatische Sicherungen wiederholt fehlschlagen.</string>
<string name="backup_failed_title">Automatische Sicherung fehlgeschlagen</string>
<string name="backup_failed_text">Calendula konnte die Sicherungsdatei nicht schreiben. Überprüfe den Sicherungsordner in den Einstellungen.</string>
<string name="special_dates_calendar_birthday">Geburtstage</string>
<string name="special_dates_calendar_anniversary">Jahrestage</string>
<string name="special_dates_calendar_custom">Besondere Termine</string>
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="settings_week_numbers">Kalenderwochen</string>
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
<string name="calendars_export_title">Kalender exportieren</string>
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
<string name="calendars_export_action">Exportieren</string>
<string name="calendars_restore_header">Wiederherstellen</string>
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
<string name="import_done_added_label">Hinzugefügt</string>
<string name="import_done_skipped_label">Duplikate</string>
<string name="import_button">Importieren</string>
<plurals name="import_title_count">
<item quantity="one">%d Ereignis wird importiert</item>
<item quantity="other">%d Ereignisse werden importiert</item>
</plurals>
</resources>

View File

@@ -403,15 +403,15 @@
<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_title">%1$s ha fallado</string>
<string name="crash_dialog_message">%1$s 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_clip_label">Reporte de error de %1$s</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_template">Gracias por reportar un error en %1$s. 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%2$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>
@@ -466,4 +466,31 @@
<string name="settings_special_dates_grant">Permitir acceso</string>
<string name="dialog_save">Guardar</string>
<string name="settings_font_system">Igual que el sistema</string>
<string name="import_reminder_prompt_title">¿Aplicar tus recordatorios por defecto?</string>
<string name="import_reminder_prompt_body_none">Este evento fue importado sin ningún recordatorio.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Este evento fue importado con %1$d recordatorio.</item>
<item quantity="many">Este evento fue portado con %1$d recordatorios.</item>
<item quantity="other">Este evento fue importado con %1$d recordatorios.</item>
</plurals>
<string name="import_reminder_prompt_apply">Aplicar por defecto</string>
<string name="import_reminder_prompt_keep">Mantener como esta</string>
<string name="back">Atras</string>
<string name="settings_week_numbers">Números de semana</string>
<string name="settings_week_numbers_summary">Muestra los números de las semanas en la vista mensual</string>
<string name="calendars_export_title">Exportar calendarios</string>
<string name="calendars_export_hint">Escoge que calendarios incluir en el archivo .ics</string>
<string name="calendars_export_action">Exportar</string>
<string name="calendars_restore_header">Restaurar</string>
<string name="calendars_restore_action">Restaurar por archivo .ics</string>
<string name="calendars_restore_hint">Importar eventos desde un respaldo u otra aplicación de calendario.</string>
<string name="import_done_dedup_note">Se omitieron los eventos ya presentes en el calendario.</string>
<string name="import_done_added_label">Añadido</string>
<string name="import_done_skipped_label">Duplicados</string>
<string name="import_button">Importar</string>
<plurals name="import_title_count">
<item quantity="one">Importando %d evento</item>
<item quantity="many">Importando %d eventos</item>
<item quantity="other">Importando %d eventos</item>
</plurals>
</resources>

View File

@@ -6,4 +6,151 @@
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendrier</string>
<string name="app_tagline">Un calendrier moderne.</string>
<string name="state_loading">Chargement…</string>
<string name="state_retry">Réessayer</string>
<string name="state_failure_unknown">Une erreur s\'est produit.</string>
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
<string name="state_failure_permission_action">Autoriser l\'accès</string>
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
<string name="state_failure_no_calendars_action">Ouvrir les paramètres du calendrier.</string>
<string name="state_failure_provider">Ne peut pas lire le calendrier.</string>
<string name="permission_rationale_title">Tous les évènements ont été trouvés.</string>
<string name="permission_rationale_body">Calendula a besoin d\'avoir accès à votre calendrier pour afficher et accéder aux événements. C\'est tout ce dont nous avons besoin, aucune données ne sort de votre appareil.</string>
<string name="permission_request_button">Autoriser l\'accès au calendrier</string>
<string name="permission_denied_title">Accès au calendrier refusé.</string>
<string name="permission_denied_body">Calendula ne peut pas afficher les évènements si elle ne peut pas accéder au calendrier. Vous pouvez l\'autoriser à tout moment dans les paramètres.</string>
<string name="permission_open_settings_button">Ouvrir les paramètres.</string>
<string name="permission_retry_button">Essayer à nouveau</string>
<string name="permission_benefit_private_title">Reste sur votre appareil</string>
<string name="permission_benefit_private_body">Vos calendriers sont lus localement et ne quittent jamais le téléphone.</string>
<string name="permission_benefit_sync_title">Tous vos calendriers, réunis</string>
<string name="permission_benefit_privacy_title">Aucun suivi, jamais</string>
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs</string>
<string name="permission_privacy_footnote">Reste sur votre appareil - Pas d\'utilisation d\'Internet</string>
<string name="month_prev">Mois précédent</string>
<string name="month_next">Prochain mois</string>
<string name="month_today_action">Aujourd\'hui</string>
<string name="month_more_actions">Plus d\'actions</string>
<string name="month_open_menu">Ouvrir le menu</string>
<string name="month_action_settings">Paramètres</string>
<string name="month_a11y_today_prefix">Aujourd\'hui</string>
<string name="week_today_action">Cette semaine</string>
<string name="day_today_action">Aujourd\'hui</string>
<string name="event_detail_back">Retour</string>
<string name="event_detail_edit">Modifier</string>
<string name="event_detail_delete">Supprimer</string>
<string name="event_detail_share">Partager</string>
<string name="event_share_chooser_title">Evénement partagé</string>
<string name="event_share_failed">Impossible de partager cet événement.</string>
<string name="event_delete_title">Evénement supprimé?</string>
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
<string name="event_delete_option_occurrence">Seulement cet événement</string>
<string name="event_delete_option_following">Cet événement et tous ceux qui suivent</string>
<string name="event_delete_option_series">Tous les événements de la série</string>
<string name="event_edit_recurring_title">Modifier l\'événement récurrent</string>
<string name="event_delete_failed">Impossible de supprimer l\'événement</string>
<string name="event_delete_write_denied">Calendula a besoin des droits d\'écriture pour supprimer des événements</string>
<string name="dialog_cancel">Annuler</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nouvel événement</string>
<string name="event_edit_close">Fermer</string>
<string name="event_edit_save">Sauvegarder</string>
<string name="event_edit_title_hint">Ajouter un titre</string>
<string name="event_edit_managed_hint">dirigé par \"%1$s\" - titre, date, et répétitions sont synchronisés avec les contacts. Pour rappel, vous pouvez modifier la localisation et les notes à tout instant.</string>
<string name="event_edit_starts">Début</string>
<string name="event_edit_ends">Fin</string>
<string name="event_edit_error_end_before_start">La fin commence avant le début</string>
<string name="event_edit_error_no_calendar">Aucun calendrier n\'est disponible en écriture</string>
<string name="event_edit_save_failed">Impossible d\'enregistrer l\'événement</string>
<string name="event_edit_write_denied">Calendula a besoin des droits d\'écriture pour créer un événement</string>
<string name="event_edit_more_fields">Plus de champs</string>
<string name="event_edit_add">Ajouter</string>
<string name="event_edit_add_reminder">Ajouter un rappel</string>
<string name="event_edit_remove_reminder">Supprimer le rappel</string>
<string name="event_edit_attendees">Invités</string>
<string name="event_edit_add_guest">Ajouter un invité</string>
<string name="event_edit_add_guest_hint">Ajouter un invité à partir d\'un email…</string>
<string name="event_edit_add_guest_from_contacts">Ajouter à partir des contacts</string>
<string name="event_edit_location_from_contacts">Sélectionner une adresse dans les contacts</string>
<string name="event_edit_remove_guest">Retirer un invité</string>
<string name="event_edit_attendee_required">Requis</string>
<string name="event_edit_attendee_optional">Optionnel</string>
<string name="event_edit_attendees_note_synced">Calendula n\'envoie pas d\'invitations. Votre calendrier par défaut peut envoyer des e-mails aux invités lors de la synchronisation.</string>
<string name="event_edit_attendees_note_local">Stocké sur cet appareil. Personne d\'autres n\'y a accès.</string>
<string name="event_edit_reminder_custom">Personnaliser</string>
<string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">heures</string>
<string name="reminder_unit_days">jours</string>
<string name="reminder_unit_weeks">semaines</string>
<string name="event_edit_availability">Disponibilité</string>
<string name="event_edit_visibility">Visibilité</string>
<string name="event_edit_color">Couleur</string>
<string name="event_edit_color_default">Couleur du calendrier</string>
<string name="event_edit_color_custom">Personnaliser la couleur</string>
<string name="event_edit_color_reset">Réinitialiser</string>
<string name="event_edit_color_unsupported">Non disponible pour ce calendrier</string>
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications?</string>
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
<string name="event_edit_conflict_discard_hint">Lévénement reste tel quil est maintenant</string>
<string name="event_edit_gone_title">Evénement supprimé</string>
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut?</string>
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
<item quantity="many">Cet événement a été importé avec %1$d rappels.</item>
<item quantity="other">Cet événement a été importé avec %1$d rappels.</item>
</plurals>
<string name="import_reminder_prompt_apply">Appliquer par défaut</string>
<string name="import_reminder_prompt_keep">Garder tel quel</string>
<string name="event_edit_recurrence_none">Pas de répétitions</string>
<string name="event_edit_recurrence_custom">Personnaliser</string>
<string name="event_edit_recurrence_every">Chaque</string>
<string name="recurrence_unit_days">jours</string>
<string name="recurrence_unit_weeks">semaines</string>
<string name="recurrence_unit_months">mois</string>
<string name="recurrence_unit_years">années</string>
<string name="event_edit_recurrence_ends">Fin</string>
<string name="event_edit_recurrence_end_never">Jamais</string>
<string name="event_edit_recurrence_end_until">A une date</string>
<string name="event_edit_recurrence_end_count">Après un nombre de fois</string>
<string name="event_edit_recurrence_times">fois</string>
<string name="event_edit_error_recurrence_ends_before_start">Les répétitions s\'arrêtent avant le début de l\'événement</string>
<string name="event_availability_busy">Occupé</string>
<string name="event_access_default">Par défaut</string>
<string name="event_access_public">Publique</string>
<string name="event_detail_all_day">Journée entière</string>
<string name="event_detail_calendar">Calendrier</string>
<string name="event_detail_calendar_unknown">Calendrier non reconnu</string>
<string name="event_detail_location">Lieux</string>
<string name="event_detail_description">Description</string>
<string name="event_detail_attendees">participants</string>
<string name="event_detail_recurrence">Récurrence</string>
<string name="event_detail_recurring">Evénement répété</string>
<string name="recurrence_daily">Tous les jours</string>
<string name="recurrence_weekly">Toutes les semaines</string>
<string name="recurrence_monthly">Tous les mois</string>
<string name="recurrence_yearly">Tous les ans</string>
<string name="recurrence_every_n_days">Tous les %1$d jours</string>
<string name="recurrence_every_n_weeks">Toutes les %1$d semaines</string>
<string name="recurrence_every_n_months">Tous les %1$d mois</string>
<string name="recurrence_every_n_years">Tous les %1$d ans</string>
<string name="recurrence_on_days">%1$s sur %2$s</string>
<string name="recurrence_with_until">%1$s jusqu\'à %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d fois</string>
<string name="event_detail_not_found">Cet événement n\'existe plus.</string>
<string name="event_attendee_accepted">Accepté</string>
<string name="event_attendee_declined">Refusé</string>
<string name="event_attendee_tentative">Provisoire</string>
<string name="event_attendee_needs_action">Aucune réponse</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Rappels</string>
</resources>

View File

@@ -16,17 +16,17 @@
<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_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</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_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</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_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>
@@ -38,7 +38,7 @@
<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="week_number_label">Sett.</string>
<string name="day_today_action">Oggi</string>
<string name="event_detail_back">Indietro</string>
<string name="event_detail_edit">Modifica</string>
@@ -73,14 +73,14 @@
<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_hint">Aggiungi un partecipante tramite indirizzo 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_attendees_note_local">Salvato sul dispositivo. Nessuno dei partecipanti 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>
@@ -89,7 +89,7 @@
<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_default">Colore del 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>
@@ -99,13 +99,13 @@
<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_conflict_discard">Annulla le modifiche</string>
<string name="event_edit_conflict_discard_hint">Lascia l\'evento invariato</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="event_edit_recurrence_every">Ogni</string>
<string name="recurrence_unit_days">giorni</string>
<string name="recurrence_unit_weeks">settimane</string>
<string name="recurrence_unit_months">mesi</string>
@@ -113,7 +113,7 @@
<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_end_count">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>
@@ -125,7 +125,7 @@
<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_daily">Ogni giorno</string>
<string name="recurrence_weekly">Ogni settimana</string>
<string name="recurrence_monthly">Ogni mese</string>
<string name="recurrence_yearly">Ogni anno</string>
@@ -204,7 +204,7 @@
<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_snooze">Posticipa</string>
<string name="reminder_action_dismiss">Ignora</string>
<string name="view_month">Mese</string>
<string name="view_week">Settimana</string>
@@ -418,16 +418,16 @@
<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>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
<string name="settings_font_headings">Carattere dell\'intestazione</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="settings_font_headings">Carattere del titolo</string>
<string name="settings_font_body">Carattere del corpo</string>
<string name="settings_font_system">Predefinito di sistema</string>
<string name="settings_font_choose_file">Scegli file…</string>
<string name="settings_font_custom_selected">Carattere personalizzato</string>
<string name="settings_font_custom_selected">Font personalizzato</string>
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
<string name="settings_section_views">Viste</string>
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_drawer_order_header">Menù di navigazione</string>
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
<string name="reorder_drag_handle">Trascina per riordinare</string>
@@ -456,4 +456,14 @@
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
<string name="settings_special_dates_disable_confirm">Spegni</string>
<string name="dialog_save">Salva</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="settings_calendar_reminders_managed_hint">Impostato nelle date speciali dei contatti</string>
<string name="special_dates_calendar_birthday">Compleanni</string>
<string name="special_dates_calendar_anniversary">Anniversari</string>
<string name="special_dates_calendar_custom">Date speciali</string>
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -6,4 +6,295 @@
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_tagline">Nowoczesny kalendarz.</string>
<string name="state_loading">Ładowanie…</string>
<string name="state_retry">Spróbuj ponownie</string>
<string name="state_failure_unknown">Coś poszło nie tak.</string>
<string name="state_failure_permission">Dostęp do kalendarza jest wymagany.</string>
<string name="state_failure_permission_action">Przyznaj dostęp</string>
<string name="state_failure_no_calendars">Brak skonfigurowanych kalendarzy.</string>
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
<string name="state_failure_provider">Nie udało się kalendarza.</string>
<string name="permission_rationale_title">Zobacz wszystkie swoje wydarzenia w pięknej oprawie</string>
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. To jedyne, o co prosi na wstępie — i żadne dane nigdy nie opuszczają Twojego urządzenia.</string>
<string name="permission_request_button">Przyznaj dostęp do kalendarza</string>
<string name="permission_denied_title">Brak dostępu do kalendarza</string>
<string name="permission_denied_body">Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych.</string>
<string name="permission_open_settings_button">Otwórz ustawienia systemowe</string>
<string name="permission_retry_button">Spróbuj ponownie</string>
<string name="permission_benefit_private_title">Pozostaje na Twoim urządzeniu</string>
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu.</string>
<string name="permission_benefit_sync_title">Wszystkie Twoje kalendarze w jednym miejscu</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia.</string>
<string name="permission_benefit_privacy_title">Nigdy żadnego śledzenia</string>
<string name="permission_benefit_privacy_body">Zero telemetrii, zero analityki, bez reklam.</string>
<string name="permission_privacy_footnote">Pozostaje na Twoim urządzeniu - brak uprawnień do internetu</string>
<string name="month_prev">Poprzedni miesiąc</string>
<string name="month_next">Następny miesiąc</string>
<string name="month_today_action">Dzisiaj</string>
<string name="month_more_actions">Więcej akcji</string>
<string name="month_open_menu">Otwórz menu</string>
<string name="month_action_settings">Ustawienia</string>
<string name="month_a11y_today_prefix">Dzisiaj</string>
<string name="week_today_action">Bieżący tydzień</string>
<string name="week_number_label">tydz.</string>
<string name="day_today_action">Dzisiaj</string>
<string name="event_detail_back">Wstecz</string>
<string name="event_detail_edit">Edytuj</string>
<string name="event_detail_delete">Usuń</string>
<string name="event_detail_share">Udostępnij</string>
<string name="event_share_chooser_title">Udostępnij wydarzenie</string>
<string name="event_share_failed">Nie udało się udostępnić tego wydarzenia.</string>
<string name="event_delete_title">Usunąć wydarzenie?</string>
<string name="event_delete_body">Usunięto wydarzenie z Twojego kalendarza i wszystkich zsynchronizowanych urządzeń.</string>
<string name="event_delete_recurring_title">Usuń powtarzające się wydarzenie</string>
<string name="event_delete_option_occurrence">Tylko to wystąpienie</string>
<string name="event_delete_option_following">To i wszystkie następne wystąpienia</string>
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
<string name="event_edit_recurring_title">Edytuj powtarzające się wydarzenie</string>
<string name="event_delete_failed">Nie udało się usunąć tego wydarzenia</string>
<string name="event_delete_write_denied">Calendula wymaga uprawnień do zapisu, aby móc usuwać wydarzenia</string>
<string name="dialog_cancel">Anuluj</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nowe wydarzenie</string>
<string name="event_edit_close">Zamknij</string>
<string name="event_edit_save">Zapisz</string>
<string name="event_edit_title_hint">Dodaj tytuł</string>
<string name="event_edit_managed_hint">Zarządzane przez „%1$s” — tytuł, data i powtarzanie są synchronizowane z Twoimi kontaktami. Możesz edytować przypomnienia, lokalizację oraz notatki.</string>
<string name="event_edit_starts">Początek</string>
<string name="event_edit_ends">Koniec</string>
<string name="event_edit_error_end_before_start">Koniec wcześniejszy niż początek</string>
<string name="event_edit_error_no_calendar">Brak dostępnych kalendarzy z uprawnieniami do zapisu</string>
<string name="event_edit_save_failed">Nie udało się zapisać wydarzenia</string>
<string name="event_edit_write_denied">Calendula potrzebuje uprawnień do zapisu by tworzyć wydarzenia</string>
<string name="event_edit_more_fields">Więcej pól</string>
<string name="event_edit_add">Dodaj</string>
<string name="event_edit_add_reminder">Dodaj przypomnienie</string>
<string name="event_edit_remove_reminder">Usuń przypomnienie</string>
<string name="event_edit_attendees">Goście</string>
<string name="event_edit_add_guest">Dodaj gościa</string>
<string name="event_edit_add_guest_hint">Dodaj gościa poprzez adres e-mail…</string>
<string name="event_edit_add_guest_from_contacts">Dodaj z kontaktów</string>
<string name="event_edit_location_from_contacts">Wybierz adres z kontaktów</string>
<string name="event_edit_remove_guest">Usuń gościa</string>
<string name="event_edit_attendee_required">Wymagany</string>
<string name="event_edit_attendee_optional">Opcjonalny</string>
<string name="event_edit_attendees_note_synced">Calendula nie wysyła zaproszeń. Twoje konto kalendarza może wysyłać e-maile do gości podczas synchronizacji.</string>
<string name="event_edit_attendees_note_local">Przechowywane na urządzeniu. Nikt nie jest powiadamiany.</string>
<string name="event_edit_reminder_custom">Niestandardowy</string>
<string name="reminder_unit_minutes">minuty</string>
<string name="reminder_unit_hours">godziny</string>
<string name="reminder_unit_days">dni</string>
<string name="reminder_unit_weeks">tygodnie</string>
<string name="event_edit_availability">Dostępność</string>
<string name="event_edit_visibility">Widoczność</string>
<string name="event_edit_color">Kolor</string>
<string name="event_edit_color_default">Kolor kalendarza</string>
<string name="event_edit_color_custom">Niestandardowy kolor</string>
<string name="event_edit_color_reset">Resetuj</string>
<string name="event_edit_color_unsupported">Niedostępne dla tego kalendarza</string>
<string name="event_edit_color_unsupported_hint">Ten kalendarz nie udostępnia zestawu kolorów. Możesz zezwolić na niestandardowe kolory dla takich kalendarzy w Ustawieniach.</string>
<string name="event_edit_color_sync_warning">Ten kalendarz może usunąć lub nadpisać kolor przy następnej synchronizacji.</string>
<string name="event_edit_conflict_title">Wydarzenie zostało zmienione w innym miejscu</string>
<string name="event_edit_conflict_body">Podczas edycji to wydarzenie zostało zmienione przez synchronizację lub inną aplikację. Co ma się stać z Twoimi zmianami?</string>
<string name="event_edit_conflict_overwrite">Zapisz moje zmiany</string>
<string name="event_edit_conflict_overwrite_hint">Tylko edytowane przez Ciebie pola nadpiszą zmiany zewnętrzne</string>
<string name="event_edit_conflict_discard">Odrzuć moje zmiany</string>
<string name="event_edit_conflict_discard_hint">Wydarzenie pozostaje bez zmian</string>
<string name="event_edit_gone_title">Wydarzenie usunięte</string>
<string name="event_edit_gone_body">To wydarzenie zostało w międzyczasie usunięte, na przykład na innym urządzeniu. Twoje zmiany nie mogą już zostać zapisane.</string>
<string name="import_reminder_prompt_title">Użyć domyślnego przypomnienia?</string>
<string name="import_reminder_prompt_body_none">To wydarzenie zostało zaimportowane bez żadnych przypomnień.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">To wydarzenie zostało zaimportowane z %1$d przypomnieniem.</item>
<item quantity="few">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
<item quantity="many">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
<item quantity="other">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
</plurals>
<string name="import_reminder_prompt_apply">Użyj domyślnego</string>
<string name="import_reminder_prompt_keep">Pozostaw bez zmian</string>
<string name="event_edit_recurrence_none">Nie powtarza się</string>
<string name="event_edit_recurrence_custom">Własne</string>
<string name="event_edit_recurrence_every">Co</string>
<string name="recurrence_unit_days">dni</string>
<string name="recurrence_unit_weeks">tygodnie</string>
<string name="recurrence_unit_months">miesiące</string>
<string name="recurrence_unit_years">lata</string>
<string name="event_edit_recurrence_ends">Koniec</string>
<string name="event_edit_recurrence_end_never">Nigdy</string>
<string name="event_edit_recurrence_end_until">Wybrana data</string>
<string name="event_edit_recurrence_end_count">Po wybranej liczbie powtórzeń</string>
<string name="event_edit_recurrence_times">razy</string>
<string name="event_edit_error_recurrence_ends_before_start">Koniec powtarzania przed początkiem wydarzenia</string>
<string name="event_availability_busy">Zajęty</string>
<string name="event_access_default">Domyślny</string>
<string name="event_access_public">Publiczny</string>
<string name="event_detail_all_day">Cały dzień</string>
<string name="event_detail_calendar">Kalendarz</string>
<string name="event_detail_calendar_unknown">Nieznany kalendarz</string>
<string name="event_detail_location">Lokalizacja</string>
<string name="event_detail_description">Opis</string>
<string name="event_detail_attendees">Uczestnicy</string>
<string name="event_detail_recurrence">Powtarzanie</string>
<string name="event_detail_recurring">Powtarzające się wydarzenie</string>
<string name="recurrence_daily">Codziennie</string>
<string name="recurrence_weekly">Co tydzień</string>
<string name="recurrence_monthly">Co miesiąc</string>
<string name="recurrence_yearly">Co rok</string>
<string name="recurrence_every_n_days">Co %1$d dni</string>
<string name="recurrence_every_n_weeks">Co %1$d tygodni</string>
<string name="recurrence_every_n_months">Co %1$d miesięcy</string>
<string name="recurrence_every_n_years">Co %1$d lata</string>
<string name="recurrence_on_days">%1$s w %2$s</string>
<string name="recurrence_with_until">%1$s do %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d razy</string>
<string name="event_detail_not_found">To wydarzenie już nie istnieje.</string>
<string name="event_attendee_accepted">Zaakceptowano</string>
<string name="event_attendee_declined">Odrzucono</string>
<string name="event_attendee_tentative">Niepewny</string>
<string name="event_attendee_needs_action">Brak odpowiedzi</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Przypomnienia</string>
<string name="event_detail_timezone">Strefa czasowa</string>
<string name="event_status_tentative">Niepewne</string>
<string name="event_status_cancelled">Odwołane</string>
<string name="event_availability_free">Dostępny</string>
<string name="event_access_private">Prywatne</string>
<string name="event_access_confidential">Poufne</string>
<string name="event_attendee_organizer">Organizator</string>
<string name="event_attendee_optional">Opcjonalny</string>
<string name="event_attendee_resource">Zasób</string>
<string name="event_detail_self_response">Twoja odpowiedź: %1$s</string>
<string name="reminder_at_time">W momencie wydarzenia</string>
<string name="reminder_default">Domyślne przypomnienie</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minuta przed</item>
<item quantity="few">%d minuty przed</item>
<item quantity="many">%d minut przed</item>
<item quantity="other">%d minut przed</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d godzina przed</item>
<item quantity="few">%d godziny przed</item>
<item quantity="many">%d godzin przed</item>
<item quantity="other">%d godzin przed</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d dzień przed</item>
<item quantity="few">%d dni przed</item>
<item quantity="many">%d dni przed</item>
<item quantity="other">%d dni przed</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d tydzień przed</item>
<item quantity="few">%d tygodnie przed</item>
<item quantity="many">%d tygodni przed</item>
<item quantity="other">%d tygodni przed</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d minuta</item>
<item quantity="few">%d minuty</item>
<item quantity="many">%d minut</item>
<item quantity="other">%d minut</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d godzina</item>
<item quantity="few">%d godziny</item>
<item quantity="many">%d godzin</item>
<item quantity="other">%d godzin</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d dzień</item>
<item quantity="few">%d dni</item>
<item quantity="many">%d dni</item>
<item quantity="other">%d dni</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">%d tydzień</item>
<item quantity="few">%d tygodnie</item>
<item quantity="many">%d tygodni</item>
<item quantity="other">%d tygodni</item>
</plurals>
<string name="event_untitled">(Bez tytułu)</string>
<string name="reminder_channel_name">Przypomnienia o wydarzeniach</string>
<string name="reminder_channel_description">Powiadomienia w czasie przypomnień o Twoich wydarzeniach</string>
<string name="reminder_onboarding_title">Nigdy nie przegap wydarzenia</string>
<string name="reminder_onboarding_body">Android sam nie wyświetla przypomnień o wydarzeniach — musi to robić aplikacja kalendarza. Pozwól na to Calenduli.</string>
<string name="reminder_benefit_delivery_title">Niezawodne przypomnienia</string>
<string name="reminder_benefit_delivery_body">Każde przypomnienie o Twoich wydarzeniach przychodzi jako powiadomienie, dokładnie na czas.</string>
<string name="reminder_benefit_duplicates_title">Używasz innej aplikacji kalendarza?</string>
<string name="reminder_benefit_duplicates_body">Jeśli inna aplikacja również wysyła przypomnienia, będą one widoczne podwójnie - wyłącz je tam lub tutaj.</string>
<string name="reminder_benefit_reversible_title">Możesz to zmienić w dowolnej chwili</string>
<string name="reminder_benefit_reversible_body">Przełącznik znajduje się w Ustawieniach, w sekcji Powiadomienia.</string>
<string name="reminder_onboarding_enable_button">Włącz przypomnienia</string>
<string name="reminder_onboarding_skip_button">Nie teraz</string>
<string name="reminder_action_snooze">Drzemka</string>
<string name="reminder_action_dismiss">Odrzuć</string>
<string name="view_month">Miesiąc</string>
<string name="view_week">Tydzień</string>
<string name="view_day">Dzień</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Widok</string>
<string name="drawer_jump_to_date">Skocz do daty</string>
<string name="agenda_today_action">Dzisiaj</string>
<string name="agenda_header_today">Dzisiaj</string>
<string name="agenda_header_tomorrow">Jutro</string>
<string name="agenda_empty_title">Jesteś na bieżąco</string>
<string name="search_action">Szukaj</string>
<string name="search_hint">Szukaj wydarzeń</string>
<string name="search_back">Wstecz</string>
<string name="search_clear">Wyczyść</string>
<string name="search_idle_hint">Przeszukuj swoje wydarzenia według tytułu, lokalizacji lub notatek.</string>
<string name="search_empty">Brak wydarzeń pasujących do „%1$s”.</string>
<string name="widget_agenda_title">Nadchodzące wydarzenia</string>
<string name="widget_agenda_label">Agenda Calendula</string>
<string name="widget_month_label">Miesiąc Calendula</string>
<string name="widget_refresh">Odśwież</string>
<string name="widget_new_event">Nowe wydarzenie</string>
<string name="widget_needs_permission">Otwórz Calendula aby przyznać dostęp do kalendarza</string>
<string name="widget_prev_month">Poprzedni miesiąc</string>
<string name="widget_next_month">Następny miesiąc</string>
<string name="widget_today">Dzisiaj</string>
<string name="filter_title">Kalendarze</string>
<string name="settings_title">Ustawienia</string>
<string name="settings_back">Wstecz</string>
<string name="back">Wstecz</string>
<string name="settings_section_appearance">Wygląd</string>
<string name="settings_theme">Motyw</string>
<string name="settings_theme_system">Systemowy</string>
<string name="settings_theme_light">Jasny</string>
<string name="settings_theme_dark">Ciemny</string>
<string name="settings_default_view">Domyślny widok</string>
<string name="settings_dynamic_color">Dynamiczne kolory</string>
<string name="settings_dynamic_color_unavailable">Wymaga Androida 12 lub nowszego</string>
<string name="settings_font_headings">Czcionka nagłówków</string>
<string name="settings_font_body">Czcionka tekstu</string>
<string name="settings_font_system">Domyślna systemu</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Wybierz plik…</string>
<string name="settings_font_custom_selected">Niestandardowa czcionka</string>
<string name="app_name">Calendula</string>
<string name="settings_font_import_failed">Nie udało się odczytać czcionki z pliku</string>
<string name="settings_week_start">Pierwszy dzień tygodnia</string>
<string name="settings_week_start_auto">Automatycznie</string>
<string name="settings_week_numbers">Numery tygodni</string>
<string name="settings_week_numbers_summary">Pokaż numery tygodni w widoku miesiąca</string>
<string name="settings_time_format">Format czasu</string>
<string name="settings_time_format_auto">Automatyczny</string>
<string name="settings_time_format_12h">12-godzinny (2:00 PM)</string>
<string name="settings_time_format_24h">24-godzinny (14:00)</string>
<string name="settings_hour_lines">Linie godzin</string>
<string name="settings_hour_lines_summary">Pokaż linię oddzielającą przy każdej godzinie w widoku tygodnia i dnia</string>
<string name="settings_dim_completed">Przyciemnij zakończone wydarzenia</string>
<string name="settings_dim_completed_summary">Przyciemnij wydarzenia, które już się zakończyły w widoku miesiąca i tygodnia</string>
<string name="settings_past_events">Przeszłe wydarzenia</string>
<string name="settings_past_events_show">Pokaż</string>
<string name="settings_past_events_dim">Przyciemnij</string>
<string name="settings_past_events_hide">Ukryj</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Zakres agendy</string>
<string name="settings_agenda_range_hint">Jak daleko w przód wyświetlane są wydarzenia na ekranie Agendy.</string>
<string name="settings_agenda_widget_range">Zakres widżetu agendy</string>
</resources>

View File

@@ -49,6 +49,7 @@
<string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string>
<string name="event_detail_share">Share</string>
<string name="event_detail_duplicate">Duplicate</string>
<string name="event_share_chooser_title">Share event</string>
<string name="event_share_failed">Couldn\'t share this event.</string>
<string name="event_delete_title">Delete event?</string>
@@ -117,6 +118,16 @@
<string name="event_edit_gone_title">Event deleted</string>
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">This event was imported with %1$d reminder.</item>
<item quantity="other">This event was imported with %1$d reminders.</item>
</plurals>
<string name="import_reminder_prompt_apply">Apply default</string>
<string name="import_reminder_prompt_keep">Keep as-is</string>
<!-- Event form — recurrence picker (v1.3) -->
<string name="event_edit_recurrence_none">Does not repeat</string>
<string name="event_edit_recurrence_custom">Custom</string>
@@ -226,6 +237,9 @@
<string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string>
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
<string name="reminder_day_tomorrow">Tomorrow</string>
<string name="reminder_day_yesterday">Yesterday</string>
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
@@ -242,6 +256,7 @@
<string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">You\'re all caught up</string>
<string name="agenda_no_more_today">No more events today</string>
<!-- Event search -->
<string name="search_action">Search</string>
@@ -278,6 +293,8 @@
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_soften_colors">Soften calendar colours</string>
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
<string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string>
@@ -308,6 +325,8 @@
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_agenda_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
<string name="settings_agenda_range_bar">Range bar</string>
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
<string name="agenda_range_day">Today</string>

View File

@@ -11,6 +11,8 @@
<locale android:name="en" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="fr" />
<locale android:name="it" />
<locale android:name="pl" />
<locale android:name="zh-CN" />
</locale-config>

View File

@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
}
@Test
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
)
val updated = original.copy(calendarId = 3L)
val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
assertThat(newId).isEqualTo(77L)
assertThat(fake.movedEvents).containsExactly(
FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
)
}
@Test
fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("insert moved event into calendar id=3")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
try {
repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("3")
}
}
@Test
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {

View File

@@ -32,6 +32,7 @@ class EventDetailMapperTest {
accessLevel: Any? = null,
timezone: String? = null,
selfStatus: Any? = null,
duration: String? = null,
): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title,
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
EventDetailProjection.IDX_DURATION to duration,
)
private fun attendeeReader(
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
// A yearly birthday/anniversary anchored before the epoch has a
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
// no DTEND (they use DURATION), so it stays end == begin.
// no DTEND (they use DURATION), so the length comes from DURATION.
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L)
}
@Test
fun `absent dtend takes its length from DURATION (issue #48)`() {
// A recurring series row has no DTEND. Opened without a named
// occurrence — a bare content://…/events/<id> VIEW intent — the row's
// own times are what render, so DURATION has to supply the length or
// the event shows as zero-length (10:0010:00).
val begin = 1_000_000_000L
val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L)
}
@Test
fun `absent dtend and absent DURATION stays zero-length`() {
val begin = 1_000_000_000L
val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin)
}
@Test

View File

@@ -220,6 +220,117 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
}
// --- buildOccurrenceCancelValues ("delete only this event") ---
@Test
fun `occurrence cancel anchors a single instance and cancels only it`() {
val values = buildOccurrenceCancelValues(
originalInstanceMillis = 1_700_000_000_000L,
dtStartMillis = 1_700_000_000_000L,
duration = "P3600S",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_700_000_000_000L)
// DTSTART + DURATION make the provider derive a single instance and drop
// the inherited RRULE, so only this occurrence is cancelled — not the
// whole series (#47). DTEND is never sent (the provider rejects it).
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.STATUS])
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
}
@Test
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
val values = buildOccurrenceCancelValues(
originalInstanceMillis = 1_700_000_000_000L,
dtStartMillis = 1_700_000_000_000L,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
assertThat(values[CalendarContract.Events.STATUS])
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
}
// --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
@Test
fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
// 2026-07-15T08:00:00Z.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
// The whole time/recurrence set rides along: an EXDATE-only update is not
// treated as a recurrence change, so the provider would leave the expanded
// instances (and the occurrence) in place. DTSTART alone is worse — it
// makes the provider recompute lastDate as a single instance and collapse
// the series to its first occurrence.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
}
@Test
fun `exdate drop appends to an existing exdate list`() {
val values = buildOccurrenceExdateValues(
existingExdate = "20260714T080000Z",
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("20260714T080000Z,20260715T080000Z")
}
@Test
fun `exdate drop folds away a repeated occurrence`() {
val values = buildOccurrenceExdateValues(
existingExdate = "20260715T080000Z",
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
}
@Test
fun `all-day exdate drop uses the date-only form`() {
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=YEARLY",
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
// --- per-event colour ---
@Test
@@ -298,4 +409,114 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
private fun masterSnapshot(
isAllDay: Boolean = false,
dtStartMillis: Long = 1_781_164_800_000L,
dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
duration: String? = null,
rrule: String? = null,
rdate: String? = null,
exdate: String? = null,
) = MasterEventSnapshot(
title = "Standup",
isAllDay = isAllDay,
dtStartMillis = dtStartMillis,
dtEndMillis = dtEndMillis,
duration = duration,
rrule = rrule,
rdate = rdate,
exdate = exdate,
timezone = "Europe/Berlin",
availability = CalendarContract.Events.AVAILABILITY_BUSY,
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
status = CalendarContract.Events.STATUS_CONFIRMED,
location = "Room 1",
description = "",
)
@Test
fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// Empty description clears explicitly; a raw/keyed colour is never copied
// (may be invalid on the target account — the copy inherits its colour).
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
}
@Test
fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
val values = buildMovedMasterValues(
masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
targetCalendarId = 9L,
uid = "u@calendula",
)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
// Recurring rows never carry DTEND (the provider's invariant).
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
}
@Test
fun `moved series without a stored duration derives it from DTEND`() {
val values = buildMovedMasterValues(
masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
targetCalendarId = 9L,
uid = "u@calendula",
)
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
}
// --- buildCopiedExceptionValues (calendar move: exception replay) ---
private fun exceptionSnapshot(
isCancelled: Boolean = false,
duration: String? = null,
// Occurrence starts one hour into the day and runs 30 minutes.
dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
) = ExceptionRowSnapshot(
exceptionEventId = 42L,
originalInstanceMillis = 1_781_164_800_000L,
isCancelled = isCancelled,
status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
title = "Moved occurrence",
isAllDay = false,
dtStartMillis = 1_781_164_800_000L + 3_600_000L,
dtEndMillis = dtEndMillis,
duration = duration,
timezone = "Europe/Berlin",
availability = CalendarContract.Events.AVAILABILITY_BUSY,
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
location = "",
description = "Notes",
)
@Test
fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
val values = buildCopiedExceptionValues(exceptionSnapshot())
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
// 30-minute occurrence derived from DTEND, written as DURATION only.
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
}
@Test
fun `copied modified occurrence keeps a stored duration verbatim`() {
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
}
}

View File

@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
val insertedForms = mutableListOf<EventForm>()
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
data class MovedEvent(
val eventId: Long,
val targetCalendarId: Long,
val original: EventForm,
val updated: EventForm,
)
val movedEvents = mutableListOf<MovedEvent>()
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val deletedEventIds = mutableListOf<Long>()
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
allDayReminderTimes += allDayReminderTimeMinutes
}
override fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
writeError?.let { throw it }
movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
allDayReminderTimes += allDayReminderTimeMinutes
return nextInsertId
}
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
private fun utcMidnight(date: LocalDate): Long =
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
/** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
private fun text(
beginMillis: Long,
endMillis: Long,
isAllDay: Boolean = false,
zone: ZoneId = berlin,
locale: Locale = Locale.GERMANY,
is24Hour: Boolean = true,
today: LocalDate? = null,
firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
): String = reminderTimeText(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = isAllDay,
zone = zone,
locale = locale,
is24Hour = is24Hour,
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
tomorrowLabel = "Tomorrow",
yesterdayLabel = "Yesterday",
)
@Test
fun `timed event on one day shows just the time range`() {
val text = reminderTimeText(
fun `timed event today shows just the time range`() {
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).isEqualTo("09:30 10:00")
}
@Test
fun `12-hour preference renders an am-pm time range`() {
val text = reminderTimeText(
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.US,
is24Hour = false,
)
assertThat(text).isEqualTo("2:00 PM 3:00 PM")
}
@Test
fun `tomorrow's event is prefixed with the tomorrow label`() {
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Tomorrow, 09:30 10:00")
}
@Test
fun `yesterday's event is prefixed with the yesterday label`() {
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Yesterday, 09:30 10:00")
}
@Test
fun `an event later this week is prefixed with the short weekday`() {
// 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("So., 09:30 10:00")
}
@Test
fun `the week boundary honours the week-start setting`() {
// Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
// Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
// and must render as a date — the opposite of the Monday-start case above.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
firstDayOfWeek = DayOfWeek.SUNDAY,
)
assertThat(text).isEqualTo("14.06.2026, 09:30 10:00")
}
@Test
fun `an event next week falls back to the exact date, not a weekday`() {
// 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("15.06.2026, 09:30 10:00")
}
@Test
fun `timed event crossing midnight includes both dates`() {
val text = reminderTimeText(
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).contains("11.06.2026")
assertThat(text).contains("12.06.2026")
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
@Test
fun `all-day single day shows one date, read in UTC`() {
val text = reminderTimeText(
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day.
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
}
@Test
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("12.06.2026")
}
@Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
val text = reminderTimeText(
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026 12.06.2026")
}
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
@Test
fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11))
val text = reminderTimeText(
val text = text(
beginMillis = day,
endMillis = day,
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
}

View File

@@ -0,0 +1,214 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventColorPaletteTest {
@Test
fun `empty palette stays empty`() {
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
}
@Test
fun `exact duplicate values collapse to the alphabetically first key`() {
val curated = listOf(
EventColorOption("cyan", 0xFF00FFFF.toInt()),
EventColorOption("aqua", 0xFF00FFFF.toInt()),
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker()
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
}
@Test
fun `small palettes pass through whole, so Google's curated set is untouched`() {
// A Google-like palette: two dozen distinct hand-picked colours.
val palette = (0 until 24).map {
val hue = it * 15
EventColorOption("$it", hsvArgb(hue.toFloat()))
}
val curated = palette.curatedForPicker()
assertThat(curated).containsExactlyElementsIn(palette)
}
@Test
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
val curated = css3Palette().curatedForPicker()
// The whole point of #22: ~147 published colours become a single
// manageable grid instead of a full screen.
assertThat(curated.size).isAtLeast(30)
assertThat(curated.size).isAtMost(60)
}
@Test
fun `curation never invents colours or drops keys`() {
val source = css3Palette()
val curated = source.curatedForPicker()
assertThat(source).containsAtLeastElementsIn(curated)
assertThat(curated.map { it.argb }).containsNoDuplicates()
}
@Test
fun `spelling-alias pairs never both survive`() {
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
val aliasPairs = listOf(
"aqua" to "cyan",
"fuchsia" to "magenta",
"gray" to "grey",
"darkgray" to "darkgrey",
"dimgray" to "dimgrey",
"lightgray" to "lightgrey",
"slategray" to "slategrey",
"lightslategray" to "lightslategrey",
"darkslategray" to "darkslategrey",
)
aliasPairs.forEach { (a, b) ->
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
}
}
@Test
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
// Black and every gray paint as the same pale swatch (the picker pins
// lightness and floors saturation), so only one survives — no stranded
// run of look-alike "pinks" at the end of the grid (#22).
val curated = listOf(
EventColorOption("black", 0xFF000000.toInt()),
EventColorOption("gray", 0xFF808080.toInt()),
EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
EventColorOption("blue", 0xFF0000FF.toInt()),
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker().map { it.key }
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
assertThat(curated).containsAtLeast("black", "red", "blue")
}
@Test
fun `a dark and a light shade of one hue collapse to a single swatch`() {
// The picker paints every swatch at one fixed lightness, so navy and a
// mid blue are indistinguishable once painted — keep just one.
val curated = listOf(
EventColorOption("navy", 0xFF000080.toInt()),
EventColorOption("blue", 0xFF0000FF.toInt()),
).curatedForPicker()
assertThat(curated).hasSize(1)
}
@Test
fun `the wheel is cut once, keeping each hue family contiguous`() {
// Twelve pure hues, deliberately shuffled; a small palette passes the
// thinning stage untouched so only the ordering is under test.
val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270)
val curated = shuffledHues
.map { EventColorOption("$it", hsvArgb(it.toFloat())) }
.curatedForPicker()
.map { it.key.toInt() }
// A proper single-seam sweep around the wheel descends exactly once
// (at the seam). The old bucketed sort could scatter a family across
// both ends, producing extra descents.
val descents = curated.indices.count { i ->
curated[(i + 1) % curated.size] < curated[i]
}
assertThat(descents).isEqualTo(1)
}
@Test
fun `CSS3 survivors span the whole rainbow`() {
val keys = css3Palette().curatedForPicker().map { it.key }
fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } }
// Which exact name represents a hue family depends on the vivid-first
// thinning, so assert each family survives, not a specific key.
assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue()
assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue()
assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue()
assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue()
assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue()
}
private fun hsvArgb(hue: Float): Int {
val h = hue / 60f
val sector = h.toInt() % 6
val f = h - h.toInt()
val q = ((1 - f) * 255).toInt()
val t = (f * 255).toInt()
return when (sector) {
0 -> argb(255, t, 0)
1 -> argb(q, 255, 0)
2 -> argb(0, 255, t)
3 -> argb(0, q, 255)
4 -> argb(t, 0, 255)
else -> argb(255, 0, q)
}
}
private fun argb(r: Int, g: Int, b: Int): Int =
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
EventColorOption(name, 0xFF000000.toInt() or rgb)
}
private val CSS3 = mapOf(
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
"yellowgreen" to 0x9ACD32,
)
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** Covers the agenda widget's "always show today" anchor logic (#35). */
class AnchorTodayTest {
private val today = LocalDate(2026, 6, 17)
private val tomorrow = LocalDate(2026, 6, 18)
@Test
fun `disabled leaves the days untouched even when today is absent`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
}
@Test
fun `enabled prepends an empty today when it is missing`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result).hasSize(2)
assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
assertThat(result[1]).isEqualTo(days.first())
}
@Test
fun `enabled anchors today into an otherwise empty list`() {
val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
assertThat(result).containsExactly(AgendaDay(today, emptyList()))
}
@Test
fun `enabled is a no-op when today already has its own day`() {
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
}
}

View File

@@ -0,0 +1,158 @@
package de.jeanlucmakiola.calendula.ui.edit
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.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.Dispatchers
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
/**
* Focuses on the save-time branch that decides between an in-place update and a
* calendar *move* (copy+delete). The provider-level move itself is verified
* on-device; here the fake records which repository call the ViewModel chose.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EventEditViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun cal(id: Long): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
)
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
),
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
)
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
// last-used calendar) completes under advanceUntilIdle instead of on a real
// IO thread the virtual clock can't observe.
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
),
)
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
),
)
private fun viewModel(
tempDir: Path,
fake: FakeCalendarDataSource,
): EventEditViewModel {
val p = prefs(tempDir)
val s = settings(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
return EventEditViewModel(repo, p, s, dispatcher)
}
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
@Test
fun `changing the calendar routes the save through a move, not an update`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L))
eventDetailResult = { detail(calendarId = 1L) }
nextInsertId = 99L
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
vm.setCalendar(2L)
vm.save()
advanceUntilIdle()
assertThat(fake.movedEvents).hasSize(1)
assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
assertThat(fake.updatedEvents).isEmpty()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
job.cancel()
}
@Test
fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L))
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
vm.setCalendar(2L)
vm.save()
advanceUntilIdle()
// No AwaitingScope park: a move is inherently whole-series.
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
assertThat(fake.movedEvents).hasSize(1)
job.cancel()
}
@Test
fun `editing a recurring event without moving still asks for the scope`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L))
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
vm.setTitle("Renamed")
vm.save()
advanceUntilIdle()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
assertThat(fake.movedEvents).isEmpty()
job.cancel()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -0,0 +1,34 @@
### Fixed
- Deleting one occurrence of a repeating event no longer breaks the series.
Choosing "This event" when deleting an occurrence of a recurring event could
wipe out every *other* occurrence while leaving the one you deleted behind as
a stale, still-tappable ghost — and deleting it again brought the series back.
A single-occurrence delete now removes exactly that occurrence and leaves the
rest of the series untouched, and the deleted occurrence disappears from the
grid straight away. This holds on every kind of calendar, including the
on-device ones Calendula keeps for contact birthdays and anniversaries, where
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
Calendula to answer the "new event" hand-off from other apps and widgets; now
it also answers the "open this event" one, so tapping an existing event in a
widget such as Todo Agenda offers Calendula and lands on that event's details.
Thanks to @bushrang3r for the report ([#48]).
- Events created from other apps get your default reminder. An event handed over
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
widget's "+" — opened with no reminder at all, ignoring the default set in
Settings. It now starts with your default reminder, the same as an event you
create in Calendula. An event opened from an `.ics` file is treated differently,
because the file has its own say: Calendula keeps whatever reminders it carries
(including none at all) and asks you once whether to apply your default instead
— it never quietly overrides the file. If you have no default set, it doesn't
ask ([#49]).
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
app (such as DAVx5), the event colour picker showed every colour the account
publishes — nearly 150 swatches in alphabetical order, many of them
duplicates or near-identical shades. The picker now shows only visually
distinct colours, arranged as a rainbow; near-duplicate shades and the
washed-out neutrals are folded away so no two swatches look alike. Picked
colours still sync exactly as before, and calendars with hand-picked
palettes (like Google's) are unaffected. Thanks to @ptab for the report
([#22]).

View File

@@ -0,0 +1,74 @@
### Added
- Show raw calendar colours. Calendula normally softens each calendar and event
colour toward a theme-fitting pastel so harsh sync colours read well on both
light and dark; a new **Soften calendar colours** setting (Settings → Design,
on by default) lets you turn that off and paint the exact colours your calendar
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
@leonp5 for the report ([#36]).
- Readable titles on dark event colours. An event bar's title now shows in white
on a dark colour and near-black on a light one, chosen automatically from the
colour's brightness, so a deep blue or purple event is legible at a glance in
the busy Week and Month views instead of dark-on-dark. This applies whether or
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
- A custom snooze duration. The **Snooze duration** setting (Settings →
Notifications) gains a **Custom…** option next to the minute presets: pick any
amount and switch between minutes and hours, so a snoozed reminder comes back
after exactly the delay you want instead of only a preset one ([#40]).
- Move an event to another calendar. When editing an existing event, the
calendar row is now tappable — pick a different calendar and saving moves the
event across, instead of having to delete it and recreate it elsewhere.
Recurring series move as a whole, keeping their individually-edited and
cancelled occurrences, and any reminders and guests come along too. A calendar
can't simply be reassigned underneath an event, so Calendula recreates it on
the target and removes the original — the same approach other calendar apps
take. Thanks to @prismplex for the suggestion ([#39]).
- Open an event straight into the edit form from another app. Calendula already
answered the "new event" and "open this event" hand-offs from other apps and
widgets; it now also answers the "edit this event" one, so an assistant, task
app, or widget can send an existing event to Calendula and land on its edit
screen rather than the read-only details. A hand-off with no event attached
opens the same prefilled create form as "new event". Calendula also recognises
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a "No more events today" note appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
- Duplicate an event. The event details now carry a **Duplicate** action that
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
a one-off like a shift or an appointment can be recreated by just changing the
day and time instead of re-typing every field. The copy keeps the original's
time, and its title, location, notes, colour, guests and reminders come along;
it's saved as its own single event (any repeat is left off — add one in the
editor if you want it). Duplicate works from read-only calendars too, dropping
the copy into a writable one. Thanks to @internet-rando for the suggestion
([#52]).
- French and Polish, in early form. Calendula has started speaking French and
Polish, both contributed as community translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are partway there, so untranslated parts still show in English until they
fill out — you can already pick either under Settings → Language or in Android's
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
(Polish) for getting them started; help finishing them is very welcome.
### Fixed
- Reminders for events on another day no longer read as if they were today. A
reminder fired ahead of time — say, the day before — used to show only the
event's time, making it look like it was happening now. The notification now
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
- Agenda dates now read in your locale's format. The agenda's range bar and its
day headers used a fixed day-month-year layout — and the range span even mixed
two orders (e.g. "15 Jul Aug 13, 2026") — instead of following your language's
conventions. Dates across the agenda and its widget now use your locale's own
field order, matching the rest of the app. The range bar also no longer repeats
the range's name from the selector button beside it, showing just the dates.
- Calendar gutters line up with the menu button. The Month view's week-number
column, and the Week and Day views' hour labels, sat a few pixels left of the
hamburger menu above them; they now line up with it. In Month view the day
cells also sit squarely under their weekday letters.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -0,0 +1,25 @@
Calendula es una aplicación de calendario moderna y de código abierto
para Android. Funciona directamente sobre el proveedor de calendario del
sistema: cualquier origen sincronizado en tu dispositivo — Nextcloud
mediante DAVx5, Google, calendarios locales, suscripciones WebCal —
aparece automáticamente, y los cambios que hagas se sincronizan de vuelta
del mismo modo.
Crea, edita y elimina eventos, incluidos los recurrentes con cambios
acotados (solo este evento / este y todos los siguientes / toda la serie)
y un sencillo selector de repetición. Calendula también entrega los
recordatorios de tus eventos como notificaciones: toca una y estarás en
el evento.
Si quieres, muestra en tu calendario los cumpleaños, aniversarios y otras
fechas especiales de tus contactos. Tú mismo lo activas; los contactos se
leen solo en tu dispositivo, nunca se suben y nunca se modifican —
Calendula solo refleja las fechas en calendarios locales que controlas
por completo.
El factor diferenciador es el diseño: auténtico Material 3 Expressive en
toda la aplicación, con Dynamic Color, movimiento expresivo y formas
expresivas.
Privacidad: cero telemetría, sin analíticas, sin acceso a la red — tus
datos nunca salen del dispositivo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -0,0 +1 @@
Un calendario moderno con Material 3 Expressive para Android.

View File

@@ -0,0 +1 @@
Calendula

View File

@@ -0,0 +1,24 @@
Calendula è un'app di calendario moderna e open source per Android.
Funziona direttamente sul provider di calendario di sistema: qualsiasi
origine sincronizzata sul tuo dispositivo — Nextcloud tramite DAVx5,
Google, calendari locali, iscrizioni WebCal — compare automaticamente, e
le modifiche che apporti vengono sincronizzate a ritroso allo stesso
modo.
Crea, modifica ed elimina eventi, compresi quelli ricorrenti con
modifiche mirate (solo questo evento / questo e tutti i successivi /
l'intera serie) e un semplice selettore di ripetizione. Calendula
recapita anche i promemoria dei tuoi eventi come notifiche: ne tocchi una
e sei subito sull'evento.
Se vuoi, mostra nel calendario i compleanni, gli anniversari e le altre
date speciali dei tuoi contatti. Sei tu ad attivarlo; i contatti vengono
letti solo sul tuo dispositivo, non vengono mai caricati e non vengono
mai modificati — Calendula si limita a rispecchiare le date in calendari
locali che controlli completamente.
La differenza sta nel design: vero Material 3 Expressive ovunque, con
Dynamic Color, animazioni espressive e forme espressive.
Privacy: zero telemetria, nessuna analisi, nessun accesso alla rete — i
tuoi dati non lasciano mai il dispositivo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -0,0 +1 @@
Un moderno calendario Material 3 Expressive per Android.

View File

@@ -0,0 +1 @@
Calendula