The picker paints every swatch through pastelize, which pins lightness to
a constant and caps saturation — so the raw palette's lightness axis is
invisible on screen. Curating in raw space (as the first pass did) left
near-identical painted swatches (navy/blue/midnightblue all paint as one
mid blue) and stranded every neutral as an identical pale tint in a run of
"pinks" at the end of the grid.
Move dedup, thinning, and ordering into the painted (pastelised) space the
user actually sees: colours that paint identically collapse to one,
oversized palettes drop washed-out neutral-origin tints (painted chroma
below a floor) and thin by CIE76 ΔE in painted Lab, and the survivors sort
continuously by painted hue with the wheel cut at its widest gap. The CSS3
147-colour dump now lands at ~33 distinct, rainbow-ordered swatches with no
neutrals stranded at the end.
The hue/saturation shaping moves into a shared domain pastelArgb() so the
picker paints exactly what curation reasons about; ui/pastelize wraps it and
only picks the theme's brightness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the picker showed a full screen of
alphabetically-scrambled, partly duplicated swatches. Palettes are now
deduped by value, thinned to perceptually distinct colours (CIE76
deltaE >= 25 in Lab) when oversized, and sorted around the hue wheel
with achromatics last. Small palettes (Google's curated set) pass
through untouched, and every surviving swatch keeps its provider
colour key so picks still round-trip through sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rework ReorderableColumn: the first cut measured each row, but grouped
cards have position-dependent padding, so slot heights varied and rows
snapped to the wrong places with no animation. Now rows are a fixed
uniform pitch, the target slot is the whole-pitches dragged, neighbours
slide aside with a spring, and the held row settles onto its slot before
the order commits. Add a lift (scale + shadow) on the held row and
cancel an in-flight settle if a new drag pre-empts it.
GroupedRow gains gapBelow so the reorderable list can own uniform
inter-row spacing.
On-device verified on Pixel 10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously only the small day-number was a tap target, so tapping the
blank area of a day cell (or its "+N" overflow) did nothing. Make every
non-event part of a day column open that day — the day number, the empty
lane cells, and the overflow row — via a shared openDayAction, so a tap
anywhere on a day opens it, matching the in-app month grid. Event bars
keep opting out to open their own detail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Views" settings section that lets users pick which views the
top-bar quick-switch button cycles through and drag to reorder them,
plus an independent drag-to-reorder for the navigation drawer's view
list. Two separate configs: a view turned off in the quick-switch
cycle is still reachable from the drawer, which always lists every view.
- QuickSwitchConfig (order + enabled set) and a drawer order persisted
in SettingsPrefs (comma-joined enum names; "!" marks a disabled view).
Missing views append enabled and unknown names drop, so a future view
defaults into both lists.
- The pill cycles through the configured enabled views in order; the
drawer renders CalendarDrawer from the drawer order. Both threaded
from CalendarHost via CalendarHostViewModel.
- New ReorderableColumn: dependency-free, measurement-driven drag
reordering for the short grouped-card settings lists (no LazyColumn,
since settings are a single verticalScroll column). Commits one write
per gesture.
- The switch needs two targets, so the last two enabled views can't be
turned off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a widget header now opens the app on a sensible top-level view,
via a shared WidgetNavRequest.OpenView(view?) — a concrete view roots
there over the default home, a null view resolves to the default home:
- Month widget: the month/year title opens the month view (#18). Its
"today" button now snaps the grid back to the current month in place
(the reset that used to sit on the title), so paging + jump-to-today
are both reachable and the title is free to open the app.
- Agenda widget: the "Upcoming" title opens the default view (#20), so
users whose home view is Week/Month can reach it in one tap instead of
drilling through a day or event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The month widget's prev/next/today controls and the agenda widget's
refresh run through Glance `actionRunCallback<T>()`, which persists the
callback's class name into the click PendingIntent and instantiates it
reflectively when tapped. Under R8 full mode (AGP 9 default) those
callback classes — only ever referenced reflectively — were renamed and
lost their no-arg constructor, so the lookup failed silently and the
arrows did nothing on release builds (while actionStartActivity taps,
which need no reflection, kept working).
Keep every ActionCallback's name and constructor. Verified against the
release mapping: ShiftMonthAction/ResetMonthAction/RefreshAgendaAction
now retain their original fully-qualified names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the disabled-calendar filtering from EventReminderReceiver into a
pure postableAlerts() function and unit-test it, since the receiver flow
itself is not reachable from a plain JUnit test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reminders fired for every due CalendarAlerts row regardless of whether
the event's calendar had been disabled in-app, even though disabled
calendars are hidden from every other surface. Carry the calendar id
through the alert and filter disabled calendars before posting, while
still marking all due alerts fired so the provider stops re-broadcasting
the suppressed ones.
Closes #17
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make a debug install unmistakable next to the production app, Flutter-style:
- src/debug resource overrides: "Calendula Debug" label and a burnt-orange
launcher-icon background (production stays slate). The .debug applicationId
suffix already lets both install side by side.
- DebugRibbon: a "DEBUG" corner ribbon overlaid in MainActivity, gated on
BuildConfig.DEBUG so release/releaseTest never show it. Enables the
buildConfig feature for the flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Saving "only this event" on a recurring event inserts a modified-occurrence
exception at Events.CONTENT_EXCEPTION_URI. buildOccurrenceExceptionValues set
the occurrence's end as DTEND, but the provider rejects that outright —
CalendarProvider2.checkAllowedInException throws
IllegalArgumentException: Exceptions can't overwrite dtend
so the insert failed, the save surfaced as SaveUiState.Failed, and the edit
screen reappeared with no change applied. "This and following" and "all events"
never go through the exception insert, which is why only "only this event" broke
(Codeberg #16, verified on a Pixel 10 / Android 16).
An exception is a single instance whose end the provider derives from
DTSTART + DURATION (clearing the inherited RRULE itself), so carry the length as
DURATION and drop DTEND — the same shape AOSP Calendar/Etar use. Verified
on-device: editing one occurrence now applies to just that occurrence and leaves
the rest of the series untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codeberg #14. Event-level multi-reminders already worked; this widens the
*defaults* layer from a single Int to a List<Int> so a calendar's default can
carry several lead times (e.g. a birthday calendar: one week before *and* on
the day).
- SettingsPrefs: global + per-calendar timed/all-day defaults become List<Int>;
CalendarReminderOverride.Minutes(List<Int>); resolveDefaultReminder returns a
list. Storage stays backward-compatible — a legacy single value ("30") parses
to [30], comma-joined for multiples, "none"/empty for no reminder.
- ReminderDefaultPicker is now multi-select: M3 Checkbox rows over the existing
grouped-tonal idiom, an exclusive "use default" group for per-calendar
overrides, and a Custom row that adds an arbitrary lead time to the set.
Optimistic local state so quick successive toggles don't race the settings
flow round-trip.
- New events seed their reminder list from the resolved default.
Tests cover none/single/multiple round-trips, legacy single-value parsing, and
per-calendar list overrides. lint + test + assembleDebug green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue #12 asks for hiding finished events in the agenda view *and* its
home-screen widget. The in-app screen already honours the Past events
setting (Show / Dim / Hide); extend the same setting to the "Upcoming"
agenda widget.
The widget loads its events unfiltered and applies the mode reactively in
the composition against a captured "now" — mirroring how the range is read
from per-instance Glance state — so toggling the setting reflects on a live
widget without relying on provideGlance's data preamble re-running. Hiding
drops finished events and any day they empty; dimming fades the colour
stripe and lowers the title's emphasis (Glance has no generic alpha
modifier). setPastEventDisplay now pushes the mode into each widget's Glance
state and recomposes, like setAgendaWidgetRange.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump versionName to 2.12.0 (versionCode 21200) and add the changelog.
Merging this into main triggers release.yaml, which builds, signs,
publishes and mints the v2.12.0 tag + Gitea release.
Closes #13
Overlapping events split a day column into narrow lanes where the title
was clipped to one or two characters. Mirror Google Calendar: drop the
time label on overlapping (multi-lane) blocks and wrap the title across
as many lines as the block height allows, so the full title stays
readable without opening the event.
Closes #13
Adds two independent display settings under Settings › Appearance, both
defaulting to the current behaviour (off) so nothing changes until a user
opts in:
• Agenda › "Past events" (Show / Dim / Hide) — events that already ended
today can be left as-is, faded, or dropped from the list. Hiding also
removes any day left empty, falling back to the empty state. Re-evaluated
each minute so rows fade/fall away as they end while the screen is open.
• Calendar › "Dim completed events" — a separate toggle that fades finished
events in the month and week grids, kept independent of the agenda setting.
An event counts as completed once its end is at or before now (in-progress
events are never dimmed; all-day events only after their day is fully over),
via a shared EventInstance.hasEnded(now). The grids read the cut-off through
a new LocalDimCutoff CompositionLocal (mirroring LocalShowHourLines) so only
the event chips recompose on the per-minute tick, and only while dimming is on.
Also adds an "Agenda" section header so the agenda rows stand apart in the
now-busier Appearance screen, and documents the feature in the changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening the new-event form now puts the cursor in the title field and raises the
keyboard, so the user can type the name straight away instead of tapping the
field first (issue #10). On by default — most events get a title — with a new
"Focus title on new event" switch in Settings → New event form to turn it off.
Only the create form auto-focuses: editing an existing event and opening a
prefilled/imported form never grab focus (guarded by !isEditing && title blank).
- SettingsPrefs: autofocusEventTitle (booleanPreferencesKey), default true.
- Plumbed through SettingsViewModel/UiState (settings switch) and
EventEditViewModel/UiState (read by the form).
- EventEditScreen: a FocusRequester on the title InlineField, requested once per
open from a LaunchedEffect when the guard holds.
- Strings (en + de), unit test for the new pref default/round-trip.
Bumps to 2.11.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Calendula didn't declare the intent filters launchers and the system use for
calendar actions, so it never appeared in the "default calendar app" chooser —
on every platform, not just GrapheneOS (issue #9). Android exposes no API for an
app to set itself default, so registering these filters is the only way users can
pick it from the system picker.
Adds to MainActivity:
- MAIN + APP_CALENDAR — the "open the calendar app" action the OS/launchers use.
- VIEW on content://com.android.calendar/time/<epochMillis> and the time/epoch
mime type — a launcher/clock date tap. The provider's time Uri is parsed into a
LocalDate and opened on the day view, rooted over the default home view (a new
sourceless WidgetNavRequest.OpenDate). The .ics import path now ignores the
calendar provider host so a date tap isn't mistaken for a file to import.
Bumps to 2.11.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review, it shouldn't be a top-level Settings entry: the "Help translate"
link now sits at the top of the full-screen App language picker (OptionPicker
gains an optional header slot). Updated README + changelog wording to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Invite community translations: a new Settings > Help translate row (next to
App language) opens the project's Weblate engage page, plus a Translations
section in the README. Documented in the 2.11.0 changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turning automatic backup off only cancelled the periodic work, so a
run-now that kept failing (e.g. its folder was deleted) retried forever
and spammed failure notifications.
- Cancel the immediate "run now" work too when backup is disabled.
- Worker no-ops (no retry) when the toggle is off, so already-queued work
can't revive itself.
- Reconcile scheduled work against saved settings on every app launch —
re-arms after a reinstall and clears orphaned work once backup is off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scheduled one-way export of local calendars to a user-chosen folder,
overwriting calendula-backup.ics each run — the manual backup, automated.
Not a sync; stays INTERNET-free (provider reads + local file write).
- WorkManager periodic job (deps: work-runtime-ktx, documentfile); worker
pulls collaborators via a Hilt @EntryPoint, so no custom WorkerFactory
wiring. First periodic run is delayed one interval so it can't race the
immediate "run now" feedback run; the writer also overwrites the canonical
file and cleans up any "(1)" duplicates from earlier races.
- SettingsPrefs: enabled, interval (minutes, floored at 30), folder Uri,
last-run status; persisted SAF write grant.
- UI in Calendars > Backup: toggle, folder picker, amount+unit interval
dialog, last-run status line. Notifies after repeated failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-account toggle switch and nested manage row with a single
trailing overflow (⋮) menu holding both account-level actions:
- "Enable all" / "Disable all" — toggle every calendar in the group
- "Manage in app" (synced) / "Add calendar" (local)
The dropdown is styled to fit: rounded corners, a distinct floating
surface (surfaceContainerLowest + lifted shadow) so it stands clear of
the cards, and a divider separating the two actions. Shortened the manage
label to "Manage in app" and dropped the now-unused account a11y strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settings:
- Event-form field rows show each field's own icon (shared
EventFormFieldVisuals, reused by the editor and settings)
- Move "Add Quick Settings tile" to a top-level Settings hub row
- Notifications: reliable-delivery + snooze moved above the
per-calendar block, which now folds behind one expandable section
Calendars manager:
- Local and synced calendars now use one collapsible group card
- Source-branded headers: each account shows its app's launcher icon
(Google Calendar, DAVx5, …) loaded from PackageManager; local shows
a device chip
- Per-account toggle-all switch (CalendarsViewModel.setAccountDisabled)
- Management action ("Add calendar" / "Manage in app") is a nested row
inside the expanded group; removed the dead "Add account" row
- A fully deactivated account dims its header, not just the switch
- Group headers use the normal row colour; their options sit one tone
darker (GroupedRow gains an optional container colour)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agenda widget loaded and sliced its data in the provideGlance preamble
and captured it as a non-reactive local. updateAll() reliably recomposes a
live Glance session but does not reliably re-run that preamble, so a range
change was redrawn against the stale slice (intermittently — only when no
session was alive did it pick up the new range). This is the same platform
limitation the month widget already works around.
Mirror that pattern: load the widest selectable window once, store the range
in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via
currentState, and slice in the composition. The settings setter writes the
state into each instance and recomposes, so a range change now reflects via
plain recomposition regardless of session lifecycle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The debounced observer was unreliable: drop(1) plus the ViewModel lifecycle
could swallow a change after re-entering the app, so a single range change
sometimes didn't refresh the widget. Refresh directly from the settings
setters instead (always fires on a real change), serialized through a mutex
so a rapid flip-and-flip-back can't run two updateAll calls at once and
strand the widget on the intermediate value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refreshing the widget directly from the setter raced when a setting was
flipped and flipped back quickly: two concurrent updateAll calls could
coalesce around a stale read and leave the widget on the intermediate value.
Observe the agenda-widget-range and week-start prefs instead and push a
single debounced updateAll once changes settle, so the widget always
converges to the final value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agenda widget only re-read agendaWidgetRange on the next data-change /
midnight / periodic refresh (or re-placement), so a settings change appeared
to do nothing until then. Push an updateAll from the settings setters:
the agenda widget on an agenda-widget-range change, and both widgets on a
week-start change (month weekday header + the agenda widget's "this week").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidate the separate "range title" and "range button" settings into a
single "Range bar" toggle, now that the header and switcher live on one bar.
On by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the session range pill from the bottom-left corner onto the same top
bar as the "Showing all upcoming events for …" header — header on the left,
pill on the right, each still independently toggleable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reword the range header to "Showing all upcoming events for". Replace the
dry empty state ("Nothing scheduled" + subtitle) with a single warmer line
("You're all caught up"), drop the subtitle, and switch the icon to a
coffee cup. The empty title is shared with the agenda widget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the "Showing all events for <window>" line out of the picker and into
the agenda view as an always-visible header above the list. Add two
settings (Agenda group, both ON by default) to toggle the range header
banner and the bottom-left range pill independently. The picker keeps its
two-list grouping but no longer carries the header.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group the range options into calendar-aligned (today / this week / this
month) and rolling (next 7 / 30 days / custom) lists. Add a header showing
the concrete span currently in effect — a single date for Today, the month
and year for This month, otherwise a start–end span — so it's clear what
the agenda is showing. The header appears only where a window is supplied
(the agenda pill), not in Settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>