Compare commits

...

35 Commits

Author SHA1 Message Date
07a8faf2df Merge pull request 'release: v2.10.0' (!40) from release/v2.10.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 6m11s
Reviewed-on: #40
2026-06-25 12:53:12 +00:00
54b059e744 chore(release): prepare v2.10.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:31:24 +02:00
d0c01ab8c4 Merge pull request 'feat(calendars): in-app enable/disable for calendars' (!38) from feat/disable-calendar into release/v2.10.0
Reviewed-on: #38
2026-06-25 12:28:31 +00:00
bf10fee19f Merge remote-tracking branch 'origin/release/v2.10.0' into feat/disable-calendar
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m57s
# Conflicts:
#	CHANGELOG.md
2026-06-25 14:23:07 +02:00
2e88c40eff Merge pull request 'feat(about): add Ko-fi support link' (!39) from feat/kofi-support into release/v2.10.0
Reviewed-on: #39
2026-06-25 12:21:37 +00:00
26e538a9ba docs(changelog): note Ko-fi support link
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:07:40 +02:00
814daa8151 docs(changelog): note in-app calendar enable/disable
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:06:39 +02:00
450fc7f7af Merge remote-tracking branch 'origin/main' into feat/disable-calendar 2026-06-25 14:05:45 +02:00
be6d10d126 feat(calendars): add app-side enable/disable for calendars
Introduce a second, heavier visibility level above the per-view hide
filter. A disabled calendar is removed from the app's surfaces entirely —
its events drop out of all views and search, and it disappears from the
drawer filter list, the event-form calendar picker and the import target
picker. It stays listed only in Settings → Calendars, where a per-row
switch toggles it, so it can always be brought back.

- CalendarPrefs: disabledCalendarIds set + setter, mirroring the hidden
  set (DataStore comma-separated string); never touches the system
  VISIBLE/SYNC_EVENTS flags, so it's app-local and reversible.
- CalendarRepositoryImpl: instances()/searchEvents() exclude
  calendarId ∈ (hidden ∪ disabled). distinctUntilChanged() on instances
  collapses the transient duplicate emission both DataStore-derived sets
  produce when either is toggled.
- FilterViewModel: drop disabled calendars from the drawer filter list.
- EventEditViewModel: exclude disabled from writableCalendars; a
  last-used preselect on a now-disabled calendar falls back to the first
  remaining writable one.
- ImportViewModel: exclude disabled from the import target list.
- CalendarsScreen/ViewModel: per-row enable/disable Switch on both the
  local and synced groups; disabled rows render dimmed (new GroupedRow
  `dimmed` flag) while keeping the toggle live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:55:10 +02:00
a1e8c05c7b docs(roadmap): mark v2.8.0 work shipped, capture disable-calendar
Update roadmap to reflect v2.8.0 releases (attendee editing, full-text
search, QS tile, now-line, contact pickers) and the week-numbers-in-month
rejection; capture the in-app 'disable a calendar' feature plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:39:01 +02:00
cecd0c2234 docs(readme): revert F-Droid badge to height 56
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:34:27 +02:00
43eb5e23e8 docs(readme): enlarge F-Droid badge to height 80
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:33:41 +02:00
946addbbcd docs(readme): use official 'Support me on Ko-fi' beige badge
Swap the Buy-me-a-coffee pill for Ko-fi's beige support badge, matched
to the F-Droid badge height (56).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:32:21 +02:00
8a63aa86a7 Update README.md 2026-06-25 11:28:44 +00:00
0ddbbb11e8 Update README.md 2026-06-25 11:28:19 +00:00
575b1c7eb7 Update ReadMe.md 2026-06-25 11:25:55 +00:00
dd61e0ed37 docs(readme): shrink Ko-fi button for visual balance
The solid Ko-fi pill read heavier than the F-Droid badge at equal
height; drop it to 46 so the two feel balanced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:23:13 +02:00
376ad5f394 docs(readme): put F-Droid and Ko-fi buttons inline at equal height
Both at height 56 on one line — equal heights align on the baseline,
so no table/border workaround is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:19:57 +02:00
8a9e6f0ee8 docs(readme): stack F-Droid and Ko-fi buttons, drop bordered table
The table centered them but Gitea draws cell borders we can't strip.
Stack the two badges on separate centered lines instead — no borders,
each keeps its own size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:09:29 +02:00
913b49e962 docs(readme): vertically center the F-Droid and Ko-fi buttons
Wrap both badges in a table row so the different-height buttons
align on a shared midline instead of the text baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:08:12 +02:00
96f4d4dc84 docs(readme): restore F-Droid badge to original height
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:07:04 +02:00
f4789c535e docs(readme): align Ko-fi button with F-Droid badge
Use Ko-fi's raster PNG button (the SVG was ignored and rendered
oversized) and match both buttons to height 56 so they sit inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:06:03 +02:00
00220c5f0e docs(readme): use official Ko-fi button next to F-Droid badge
Replace the small shields Ko-fi badge with Ko-fi's official
"Support me" button, placed beside the Get it on F-Droid badge
as a peer call-to-action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:04:15 +02:00
b7a5f16c84 docs(readme): replace inline sections with doc links
Move the Building section into docs/BUILDING.md and collapse Building,
Architecture, and Roadmap into a compact Documentation block linking to
the dedicated docs, keeping the README focused on what/install/support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:56:26 +02:00
2c38912834 docs(readme): surface Ko-fi + official F-Droid, tidy install
- Move support to the top: Ko-fi badge in the header badge row,
  drop the redundant bottom Support section
- Add "Get it on F-Droid" button now that Calendula is on the
  official repo
- Restructure Install: lead with official F-Droid, keep the
  self-hosted repo as the faster-updates lane

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:53:01 +02:00
a9c67a5827 feat(about): add Ko-fi support link
Add an optional way for users to support development:

- About card: tonal "Support development" button opening ko-fi.com
  via ACTION_VIEW (no INTERNET permission), EN + DE strings
- F-Droid metadata: Donate field in self-hosted and official drafts
- README: short Support section with the Ko-fi link

No perks/rewards attached — kept as a plain donation link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:49 +02:00
a69b161178 Merge pull request 'release: v2.9.0' (!37) from release/v2.9.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 8m38s
Reviewed-on: #37
2026-06-25 08:11:37 +00:00
024deadf78 chore(release): prepare v2.9.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m5s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:54 +02:00
599c3c03e7 Merge pull request 'feat(ui): consistent motion polish, predictive back & reduced-motion' (!36) from feat/ui-polish-animations into release/v2.9.0
Reviewed-on: #36
2026-06-25 07:52:32 +00:00
5d48c90408 docs(changelog): note motion polish, predictive back & reduced-motion
All checks were successful
CI / ci (pull_request) Successful in 4m27s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:20:45 +02:00
c6d15d0118 feat(ui): consistent motion polish, predictive back & reduced-motion support
Centralise the app's motion vocabulary in CalendarTransitions.kt and route
every surface through it so animation is consistent app-wide:

- Shared expand/collapse, list-item and fade-through helpers; the event-edit
  expand pattern is now the shared one (no duplicate).
- Settings reminder-override rows and the reminder Custom field now expand/
  collapse instead of bare-fading.
- Search and agenda rows animate (fade/relocate) via animateItem.
- Month/week/day slide and the onboarding gates honour the new helpers.
- View switching (month/week/day/agenda) now fades through instead of
  snapping — lateral navigation per M3, while in-view paging keeps its slide.

Add full predictive-back support:

- enableOnBackInvokedCallback in the manifest.
- New Modifier.predictiveBack(onBack) drives the standard preview transform
  (scale/shift/round) following the back gesture; applied to detail, edit,
  search, settings (+ sub-screens & calendar manager via CollapsingScaffold),
  the calendar editor and import — each keeping its existing back semantics.

Reduced-motion guardrail throughout: rememberReduceMotion() (reads the OS
"remove animations" setting, which Compose ignores by default) collapses
spatial motion to a quick fade and skips the back preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:19:35 +02:00
79e083b4d4 Merge pull request 'feat: configurable default view + widget-aware back navigation' (!35) from feat/view-back-stack into release/v2.9.0
Reviewed-on: #35
2026-06-25 06:25:15 +00:00
119a8afe8e feat(nav): interactive month widget + history-retracing back
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 4m18s
On-device review follow-ups:

- Month widget grid is now tappable. Day numbers open that day and event
  bars open the event's detail, both rooted in the month view so back
  returns to the grid. Previously only the prev/next/today header
  controls responded — the grid cells were never clickable.

- Pill/drawer view switches now build a visit history instead of
  collapsing to the default view. Back retraces the views you moved
  through (a not-yet-visited view is pushed; revisiting one collapses the
  loop back to it), down to the default, then exits. Widget launches
  still reset to their own view context.

Refs #1, #2 (reported by @devinside).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:17:23 +02:00
b537e143f8 feat(nav): default view setting + widget-aware back stack
Replace CalendarHost's single global view slot with a top-level view
back stack rooted at a user-configurable default view. A lateral move
(pill/drawer/widget) replaces the non-home top; a date tap drills the
day view on top; a base-level BackHandler pops one level until only the
home view remains, then the system exits.

Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch
roots the stack in that widget's view: backing out of a day/event opened
from the agenda widget returns to Agenda, and from the month widget to
Month, instead of always landing on Week. Reminder taps keep the
separate detail-key channel and leave the base view untouched.

Add a Default view setting (Settings -> Appearance) backing the stack's
home view; defaults to Week so existing users see no change.

Resolves the two Codeberg reports from @devinside:
- #1 [FR] Option to set default view
- #2 [Bug] Widget UX improvement

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:07:00 +02:00
faf2f27eda Merge pull request 'release: v2.8.0' (!34) from release/v2.8.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 30s
Release — F-Droid repo + Gitea release / release (push) Successful in 6m18s
Reviewed-on: #34
2026-06-25 05:18:55 +00:00
45 changed files with 1257 additions and 242 deletions

View File

@@ -230,7 +230,7 @@ pass on the existing controls; new toggles ride in with their own features.
**Tier 3 — platform reach (depends on Tier 2)**
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
@@ -251,37 +251,38 @@ pass on the existing controls; new toggles ride in with their own features.
decision (snooze must re-fire an alarm).
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
12. **Attendee editing** *(next — promoted from the gated "Locations & People"
bucket, owner says high-importance 2026-06-22)* — attendees are already
*read* (queried, mapped, shown on the detail screen since v0.6) but the
event form can't *write* them, the last big read-only gap in the event
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
typed email (contact-picker entry can follow later), edit/remove rows, set
role (required / optional) — writing `CalendarContract.Attendees` rows on
insert + dirty-checked update, mirroring the reminders-diff pattern.
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
*invitation* behavior that differs by backend (Google auto-emails invites;
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
decide and document what Calendula promises before building. Full sketch in
"Attendee editing" under Locations & People below.
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
the event model: attendees were already *read* (queried, mapped, shown on the
detail screen since v0.6) and are now *writable* from the form. An attendees
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
no-permission contact picker, edits/removes rows, and sets role (required /
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
caveat was **settled record-only** (Calendula never sends invites; the backend
decides delivery) — full sketch + decision in "Attendee editing" under Locations
& People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
- Move event to another calendar — sync-adapter minefield (copy+delete model)
**Bottom — deprioritised, not important**
- Duplicate event (detail action → prefilled create form) — moved here
2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search. Pulled in opportunistically, not sequenced.
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
Pulled in opportunistically, not sequenced.
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected).
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
(quick-settings tile, now-line, week numbers in month, full-text search).
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers
full-text search, the "New event" Quick Settings tile, and the now-line all
shipped there. **No tier work is currently committed as next;** the remaining
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
accessibility pass) plus the gated go/no-go items.
## Navigation & views
@@ -291,24 +292,22 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
- Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1)
- Current-time "now" line in day/week — standard in every calendar, cheap,
currently absent. Daily-driver polish.
- Week numbers in the **month** grid — week view already shows the badge
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
the view and shrinks the day cells; the badge stays week-view-only.
- Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
with real event history, finding an event is core completeness, not optional.
- ~~Full-text search~~ **shipped v2.8.0** for a daily driver with real event
history, finding an event is core completeness, not optional.
## Event editing & creation
- Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
*(next)* — chosen to follow the in-progress tap-to-create + calendar
management work: reuses the color-picker component and palette plumbing
being built for local calendar management, and finishes the create/edit
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
- ~~**Per-event color**~~ *(shipped v2.4.0)*`EVENT_COLOR` / `EVENT_COLOR_KEY`
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
picker in the form, falling back to the calendar color when unset. Reused the
color-picker component and palette plumbing from local calendar management and
finished the create/edit theme.
## Calendars & accounts
@@ -337,7 +336,63 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
UID). Closed the silent data-loss gap.
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
### Disable a calendar in-app *(captured 2026-06-25)*
A second, heavier visibility level **above** the existing per-view filter. Today
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
a calendar's *events* from the month/week/day/agenda views — the calendar itself
still clutters the drawer filter list, the event-form calendar picker, and the
import target picker. "Disable" removes a calendar from the app's surfaces
entirely; "hide" stays the lightweight, frequently-toggled control.
**Two-level model (both kept):**
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
drawer. Toggles events on/off in the views; the calendar stays listed
everywhere. Operates only over the *enabled* calendars.
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
filter list, not in the event-form picker, not in the import picker, and its
events never appear (it's gated out of `instances()` like a hidden one). It
remains visible **only** in Settings → Calendars, where the enable/disable
toggle lives, so it can be brought back.
**Storage — app-side (DataStore), mirrors the hidden set.** Add
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
a Calendula-local preference, so other calendar apps are unaffected and the sync
adapters stay out of it (privacy-clean, reversible).
**Where the disabled set is applied:**
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
`calendarId ∈ (hidden disabled)` so a disabled calendar's events never show
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
the screens that need everything still get everything.)
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
(you can't hide/show what's disabled).
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
into a calendar you've removed from the app. Handle the last-used-calendar
preselect falling on a now-disabled calendar (fall back to first enabled
writable).
- `ImportViewModel` — exclude disabled from the import target list.
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
row on the existing `CalendarsScreen` (both the local and the synced/read-only
groups — disabling is an app-side view choice, independent of write access).
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
**Decided behaviour:**
- All calendars disabled → views show the existing empty state.
- A disabled calendar that still holds events: events simply vanish from
views/search until re-enabled (no data touched — it's a filter, not a delete).
- Deep links / notifications pointing at an event in a disabled calendar still
open its detail screen — detail is a direct id lookup, not an `instances()`
query, so disabling never strands an existing link.
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
Two themes bundled because both are "make reminders trustworthy" — the core of
the "Calendula is your only calendar app" promise.
@@ -414,18 +469,20 @@ it directly undermines the feature's premise, so it rides in here.
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
are notorious for suppressing it).
### Open decisions (resolve before building)
### Decisions made (as shipped in v2.6.0)
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
(reliability vs simplicity + battery cost).
2. All-day reminder representation (minutes-before vs absolute time-of-day).
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
a list inside the Notifications sub-page).
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
battery-optimisation exemption is the reliability lever (simplicity + battery
cost won over the exact-alarm subsystem).
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
time-of-day ("9am the day before") deferred — see the deferred list above.
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
governing timed events only.
### Later (round two)
### Round two
- Snooze + dismiss actions on the notification (snooze needs an
exact-alarm / WorkManager decision) — Tier 4 #13.
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
## Sharing & interop
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
## Platform & launchers
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
optional quick-settings tile still open
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## Quality & reliability
@@ -455,9 +512,10 @@ it directly undermines the feature's premise, so it rides in here.
Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet:
- **Contact address picker** for the location field via the system picker
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
fits the privacy story. Same mechanism later for picking emails.
- ~~**Contact address picker** for the location field via the system picker
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
the privacy story. The same no-permission mechanism was then reused for the
attendee email picker (v2.8.0).
- **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the

View File

@@ -5,6 +5,58 @@ 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.10.0] — 2026-06-25
### Added
- Turn a calendar off across the whole app. Settings → Calendars now has a
switch on every calendar — both your own and synced ones. Switching one off
removes it everywhere: its events disappear from all views and search, and it
drops out of the drawer's hide/show filter, the event-form calendar picker and
the import target picker. Unlike hiding (the quick per-view checkbox in the
drawer), a disabled calendar leaves the app entirely until you turn it back on.
Nothing is deleted and no other app is affected — it's a Calendula-only view
choice — so you can re-enable it any time from the same screen, where disabled
calendars stay listed but dimmed.
- An optional way to support development. Settings → About now has a "Support
development" button that opens Ko-fi in your browser. It's a plain donation
link with no perks attached, and it needs no new permissions — Calendula still
has no internet access of its own and just hands the link to your browser.
## [2.9.0] — 2026-06-25
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).
## [2.8.0] — 2026-06-23
### Added
@@ -572,3 +624,6 @@ automatically, with zero telemetry and no internet permission.
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
- F-Droid metadata stubs (DE + EN short/full descriptions)
- `.planning/` project-tracking documents
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2

View File

@@ -15,6 +15,12 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
</p>
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
@@ -68,13 +74,20 @@ database, no sync stack reinvented.
## 📦 Install
Calendula ships through a self-hosted F-Droid repository; every version tag
is built, signed, and published there automatically.
### F-Droid (recommended)
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
Store, …).
2. Add the repository — open this link on your phone, or paste it under
*Settings → Repositories → Add*:
Calendula is on the **official [F-Droid](https://f-droid.org) repository**
just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
### Self-hosted repo (latest builds)
New versions are built, signed, and published to a self-hosted repository the
moment each tag lands — usually a few days ahead of the official repo, which
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
@@ -84,36 +97,16 @@ is built, signed, and published there automatically.
fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
3. Refresh, search for **Calendula**, install. Updates arrive like any
other F-Droid app.
2. Refresh, search for **Calendula**, install.
Alternatively, build from source — see below.
Both channels share the same signing key, so you can switch between them
without reinstalling. Or build from source — see below.
## 🛠 Building
## 📚 Documentation
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
```bash
./gradlew assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
If your default JDK is not 17, set `JAVA_HOME` explicitly.
## 🏗 Architecture
Single-activity Compose app, layered `UI → Repository → DataSource →
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
including the recurring-write and reminder pipelines — lives in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## 🗺 Roadmap
Shipped: read (v1.0), write (v1.1v2.0), reminder delivery (v1.4).
Next up: power-user features — widget, search, tablet layouts. The living
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
history in [CHANGELOG.md](CHANGELOG.md).
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
## 📜 License

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 = 20800
versionName = "2.8.0"
versionCode = 21000
versionName = "2.10.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -39,6 +39,7 @@
<application
android:name=".CalendulaApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View File

@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
@@ -140,18 +141,38 @@ class MainActivity : AppCompatActivity() {
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
else -> null
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
// A widget event tap carries both the occurrence and its source view;
// reminders (no source) fall through to [detailKeyOrNull] instead.
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
eventId = eventId,
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
source = source,
)
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
else -> null
}
}
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
private fun Intent.sourceViewOrNull(): CalendarView? =
getStringExtra(EXTRA_SOURCE_VIEW)
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
private fun Intent.detailKeyOrNull(): LongArray? {
// A widget event tap (source present) is routed through [navRequestOrNull]
// so it can also set the base view; only sourceless reminder taps land here.
if (sourceViewOrNull() != null) return null
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
@@ -168,6 +189,10 @@ class MainActivity : AppCompatActivity() {
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
// Fired by the launcher long-press "New event" shortcut (res/xml/
// shortcuts.xml hardcodes this string — keep the two in sync).
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
@@ -190,14 +215,35 @@ class MainActivity : AppCompatActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Open the day view anchored on [date] (home-screen widgets). */
fun openDateIntent(context: Context, date: LocalDate): Intent =
/**
* Open the day view anchored on [date], drilled in over the launching
* widget's [source] view (home-screen widgets). Backing out of the day
* returns to [source], then to the default home view.
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open one occurrence's detail from a widget event tap, rooting the back
* stack in the widget's [source] view. Same occurrence-key shape as
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
* tap (sets the base view) from a reminder tap (leaves it untouched).
*/
fun openEventIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
.reQuery { dataSource.calendars() }
.flowOn(io)
// Instances are filtered by the app-side hidden-calendar set (M3): an event
// is dropped whenever the user has hidden its calendar. Re-runs when the
// provider ticks *or* the hidden set changes — toggling a calendar in the
// filter sheet updates every view immediately. [calendars] stays unfiltered
// so the filter sheet can list and re-enable hidden calendars.
// Instances are filtered by the app-side hidden disabled calendar sets
// (M3): an event is dropped whenever the user has hidden *or* disabled its
// calendar. Re-runs when the provider ticks *or* either set changes —
// toggling a calendar in the filter sheet or the calendar manager updates
// every view immediately. [calendars] stays unfiltered so those screens can
// list and re-enable hidden/disabled calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine(
ticks
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
)
},
prefs.hiddenCalendarIds,
) { instances, hidden ->
if (hidden.isEmpty()) instances
else instances.filterNot { it.calendarId in hidden }
}.flowOn(io)
prefs.disabledCalendarIds,
) { instances, hidden, disabled ->
val excluded = hidden + disabled
if (excluded.isEmpty()) instances
else instances.filterNot { it.calendarId in excluded }
}
// hidden and disabled both derive from one DataStore, so toggling
// either makes both re-emit and combine briefly surfaces the same
// list twice — collapse the duplicate so views don't re-render for it.
.distinctUntilChanged()
.flowOn(io)
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val hidden = prefs.hiddenCalendarIds.first()
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
dataSource.searchEvents(query)
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =

View File

@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
}
}
/**
* App-side preference for "calendars the user has disabled in this app" — a
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
* every surface (drawer filter, event-form picker, import picker) and its
* events never appear; it stays listed only in Settings → Calendars so it can
* be re-enabled. Stored exactly like the hidden set; never touches the
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
*/
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
prefs[DISABLED_IDS_KEY].orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
}
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
store.edit { prefs ->
if (ids.isEmpty()) {
prefs.remove(DISABLED_IDS_KEY)
} else {
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
}
}
}
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
}
}

View File

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -69,6 +70,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[WEEK_START_KEY] = pref.name }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
* until they pick another. Also the bottom of the in-app view back stack, so
* back from any other view returns here before exiting.
*/
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
}
suspend fun setDefaultView(view: CalendarView) {
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
}
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
@@ -244,6 +259,7 @@ class SettingsPrefs @Inject constructor(
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")

View File

@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -12,15 +14,22 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
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
@@ -35,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
* Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
* (see [selectView]); a widget launch resets the stack to its own view; a date
* tap drills the day view on top. Pressing back pops one level, and the
* base-level [BackHandler] hands off to the system to exit once only the home
* view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse.
*
* [requestedDetailKey] is an externally requested occurrence (a tapped
* reminder notification routed through MainActivity): it opens the detail
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
* so a later recomposition can't re-open it.
* so a later recomposition can't re-open it. (A widget event tap instead arrives
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/
@Composable
fun CalendarHost(
@@ -53,15 +72,24 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
// Wait for the persisted default view before seeding the stack, so the app
// opens straight on the user's choice instead of flashing a placeholder and
// correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
view = CalendarView.Day
viewStack = viewStack.drillToDay()
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -156,17 +184,30 @@ fun CalendarHost(
importForm = null
}
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
// 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
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Reveal the day view: drop any overlay that would cover it, so
// an external date-open doesn't land under an open Settings/form.
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
view = CalendarView.Day
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
@@ -189,41 +230,61 @@ fun CalendarHost(
val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null ||
importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
}
Box(modifier = modifier.fillMaxSize()) {
when (view) {
CalendarView.Week -> WeekScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = view,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
// Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = calendarFadeThrough()
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
label = "view-switch",
) { currentView ->
when (currentView) {
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
}
}
// Search overlay — below detail/edit in the Box so a tapped result's
@@ -338,3 +399,9 @@ fun CalendarHost(
}
}
}
/** Persists the view back stack across config change / process death by ordinal. */
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
* nullable [StateFlow] so the host can wait for DataStore's first emission before
* seeding its view back stack — rendering nothing for that first frame rather
* than flashing the old hard-coded Week view and then correcting it.
*/
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
) : ViewModel() {
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
}

View File

@@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.Crossfade
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
@@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RootScreen(
modifier: Modifier = Modifier,
@@ -51,32 +55,40 @@ fun RootScreen(
onDispose { lifecycle.removeObserver(obs) }
}
if (hasPermission) {
// Second onboarding gate (v1.4, one-time): reminder notifications.
// Null until DataStore's first emission — render nothing for that
// frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
when (onboardingDone) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
// Cross-fade the one-time onboarding gates so granting permission / finishing
// onboarding eases into the next screen instead of snapping. A fade carries no
// spatial motion, so it stays appropriate under "remove animations" too.
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
if (granted) {
// Second onboarding gate (v1.4, one-time): reminder notifications.
// Null until DataStore's first emission — render nothing for that
// frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
null -> {}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
}
}

View File

@@ -1,14 +1,28 @@
package de.jeanlucmakiola.calendula.ui
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* A navigation a home-screen widget asked the app to perform when launched.
* Parsed from the launch intent in MainActivity and consumed once by
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
* they are not modelled here).
* [CalendarHost]. Every request carries the [source] view of the widget it came
* from (the agenda widget → [CalendarView.Agenda], the month widget →
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
* backing out of the opened date/event returns to the widget's own view, not the
* default home. (Reminder notifications are not widgets — they keep the separate
* detail-key channel and leave the base view untouched.)
*/
sealed interface WidgetNavRequest {
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
data class OpenDate(val dateIso: String) : WidgetNavRequest
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val source: CalendarView,
) : WidgetNavRequest
/** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest

View File

@@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -190,6 +191,7 @@ private fun AgendaList(
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
@@ -221,11 +223,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaEventRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = modifier,
title = title,
summary = agendaTimeSummary(event),
position = position,

View File

@@ -4,7 +4,6 @@ import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -31,7 +30,6 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Palette
@@ -46,6 +44,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -59,11 +58,14 @@ 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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
@@ -73,6 +75,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
@@ -98,6 +101,7 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
@@ -134,6 +138,7 @@ fun CalendarsScreen(
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
disabledIds = disabledIds,
error = error,
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
@@ -142,6 +147,7 @@ fun CalendarsScreen(
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled,
)
}
}
@@ -150,6 +156,7 @@ fun CalendarsScreen(
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
disabledIds: Set<Long>,
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
@@ -158,6 +165,7 @@ private fun CalendarsList(
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit,
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@@ -206,19 +214,21 @@ private fun CalendarsList(
if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty))
}
HintText(stringResource(R.string.calendars_disable_hint))
val localCount = local.size + 1
local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, localCount),
leading = { CalendarColorChip(calendar.color) },
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.calendars_edit_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
onClick = { onEdit(calendar) },
@@ -258,10 +268,19 @@ private fun CalendarsList(
.forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType)
cals.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
leading = { CalendarColorChip(calendar.color) },
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
)
}
}
@@ -295,10 +314,9 @@ private fun CalendarEditor(
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
BackHandler(onBack = onClose)
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {
@@ -425,6 +443,30 @@ private fun CalendarEditor(
}
}
/**
* The per-row enable/disable control. Checked = the calendar is shown in the
* app; unchecking disables it (events, filters and pickers all drop it) without
* touching any provider data. Carries its own content description so the toggle
* is self-describing to screen readers even on a dimmed row.
*/
@Composable
private fun EnableSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier
/** Tonal field card matching the event editor's design (icon + content). */
@Composable
private fun EditorCard(

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -33,6 +35,7 @@ import javax.inject.Inject
class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -46,6 +49,20 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(),
)
/**
* Calendars the user has disabled in the app. This screen is the only
* surface that lists them, so it both reads the set (to dim the rows) and
* toggles it. Every other surface simply excludes these ids.
*/
val disabledCalendarIds: StateFlow<Set<Long>> =
prefs.disabledCalendarIds
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -93,6 +110,19 @@ class CalendarsViewModel @Inject constructor(
repository.deleteCalendar(id)
}
/**
* Enable or disable a calendar app-side. Disabling removes it from every
* surface but Settings → Calendars (and hides its events) without touching
* provider data — purely a reversible Calendula-local view choice.
*/
fun setDisabled(id: Long, disabled: Boolean) {
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch {
try {

View File

@@ -1,14 +1,56 @@
package de.jeanlucmakiola.calendula.ui.common
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Whether the user has asked the system to remove animations (Settings →
* Accessibility → "Remove animations", which sets the global animator duration
* scale to 0). Compose animations do *not* honour this platform flag on their
* own, so the shared motion helpers in this file check it and fall back to a
* quick cross-fade — opacity only, no spatial movement — to respect the
* vestibular intent of the setting while keeping state changes legible.
*
* Read once at composition; the scale changes rarely and only takes full effect
* after a process restart anyway.
*/
@Composable
fun rememberReduceMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks.
* The fast effects spec from the active motion scheme, for opacity (fade)
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks/days.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
*/
fun calendarSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
}
/**
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
* opacity, so it doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarFadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}
/**
* Enter transition for a vertically-revealed section (expandable rows, inline
* fields): height grows from the top while fading in. Under reduced motion the
* height growth is dropped, leaving a quick fade.
*
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
* originally inlined in the event edit form, so every expandable surface in the
* app reveals the same way.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/**
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
* motion keeps the fade only.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
}
/**
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
* app's motion scheme instead of Compose's default spring. Returns a bare
* [Modifier] under reduced motion so rows snap into place. Requires the list to
* supply stable item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Standard Android predictive-back transform for a full-screen overlay: as the
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
* swiped edge and rounds its corners, previewing what's behind. Completing the
* gesture invokes [onBack]; cancelling springs it back.
*
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
* once and apply the returned [Modifier] to that screen's root so the preview
* respects the same back semantics. Under reduced motion the visual preview is
* skipped (the back still works); on API < 34 the system delivers no progress,
* so it degrades to a plain back.
*/
@Composable
fun Modifier.predictiveBack(
onBack: () -> Unit,
enabled: Boolean = true,
reduceMotion: Boolean = rememberReduceMotion(),
): Modifier {
val progress = remember { Animatable(0f) }
var fromLeftEdge by remember { mutableStateOf(true) }
PredictiveBackHandler(enabled = enabled) { events ->
try {
events.collect { event ->
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
}
onBack()
progress.snapTo(0f)
} catch (_: CancellationException) {
progress.animateTo(0f)
}
}
if (reduceMotion) return this
return this.graphicsLayer {
val p = progress.value
val scale = 1f - 0.1f * p
scaleX = scale
scaleY = scale
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
shape = RoundedCornerShape(32.dp.toPx() * p)
clip = p > 0f
}
}

View File

@@ -49,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size]
}
/**
* The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view
* remains, then the system exits the app. Three operations move it:
*
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
* history so back retraces it: switching to a not-yet-visited view pushes it on
* top; switching to one already in the stack pops back to it (collapsing the
* loop, so the depth stays bounded by the number of distinct views). Selecting
* the home view collapses to just `[default]`.
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
* the widget's view is the home view, else `[default, source]`. A widget entry
* starts a fresh context rather than extending in-app history.
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
* a widget), unless the day view is already current.
*/
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
val existing = indexOf(target)
return if (existing >= 0) take(existing + 1) else this + target
}
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
if (source == default) listOf(default) else listOf(default, source)
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -73,11 +72,11 @@ fun CollapsingScaffold(
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(onBack = onBack)
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -128,7 +127,9 @@ fun CollapsingScaffold(
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
* corner radii come from its [position] (so a run of rows reads as a single
* rounded card). Corners round further on press. A null [onClick] makes the
* row non-interactive (e.g. read-only entries).
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
* summary to the M3 disabled emphasis while leaving the [trailing] control at
* full opacity — for rows that are present but switched off.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -138,6 +139,7 @@ fun GroupedRow(
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
dimmed: Boolean = false,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
@@ -169,6 +171,16 @@ fun GroupedRow(
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else if (dimmed) {
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
// stays full-opacity so the toggle reads as live even on a faded row.
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = muted,
leadingIconColor = muted,
supportingColor = muted,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}

View File

@@ -173,7 +173,11 @@ fun ReminderDefaultPicker(
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(visible = customExpanded) {
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },

View File

@@ -75,6 +75,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -237,6 +239,8 @@ private fun DayContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
@@ -287,7 +291,7 @@ private fun DayContent(
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {

View File

@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -92,6 +91,7 @@ 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.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -132,8 +132,6 @@ fun EventDetailScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@@ -214,6 +212,7 @@ fun EventDetailScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(

View File

@@ -8,14 +8,9 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -63,7 +58,6 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -121,6 +115,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
@@ -202,8 +199,6 @@ fun EventEditScreen(
viewModel.reset()
onClose()
}
BackHandler(onBack = close)
// The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) {
@@ -253,6 +248,7 @@ fun EventEditScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = close),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -410,7 +406,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
* spring open with the expressive spatial spec instead of popping in.
* (Initially-visible sections render without animating.)
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun OptionalFormSection(
visible: Boolean,
@@ -418,10 +413,8 @@ private fun OptionalFormSection(
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}

View File

@@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor(
val allowColorOnUnsupported: Boolean,
)
/** Writable calendars — the only valid event targets. */
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) }
/**
* Writable calendars — the only valid event targets. Disabled calendars are
* excluded, so you can't create into a calendar you've removed from the app;
* a last-used preselect landing on a now-disabled calendar falls back to the
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
*/
private val writableCalendars: Flow<List<CalendarSource>> = combine(
repository.calendars(),
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(

View File

@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
combine(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
if (calendars.isEmpty()) {
prefs.disabledCalendarIds,
) { calendars, hidden, disabled ->
// Disabled calendars are gone from the app entirely — they don't
// belong in the drawer's hide/show list (you can't hide what's
// already disabled). They live only in Settings → Calendars.
val enabled = calendars.filterNot { it.id in disabled }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else {
FilterUiState.Success(groupByAccount(calendars, hidden))
FilterUiState.Success(groupByAccount(enabled, hidden))
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
@@ -57,7 +57,6 @@ fun ImportScreen(
) {
LaunchedEffect(uri) { viewModel.load(uri) }
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(onBack = onClose)
// A single event isn't shown here — it opens the create form for review.
LaunchedEffect(state) {
@@ -65,7 +64,9 @@ fun ImportScreen(
}
Scaffold(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.import_title)) },

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
class ImportViewModel @Inject constructor(
private val repository: CalendarRepository,
private val importer: IcsImporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
else -> ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first().filter { it.canModifyContents },
)
else -> {
// A disabled calendar is removed from the app, so it can't be
// an import target — exclude it alongside the read-only ones.
val disabled = prefs.disabledCalendarIds.first()
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
)
}
}
}
}

View File

@@ -66,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -217,6 +219,8 @@ private fun MonthContent(
val threshold = with(density) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
@@ -243,7 +247,7 @@ private fun MonthContent(
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "month-transition",
) { s ->
when (s) {

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -49,6 +48,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
@@ -88,10 +89,8 @@ fun SearchScreen(
focusRequester.requestFocus()
keyboard?.show()
}
BackHandler(onBack = onBack)
Scaffold(
modifier = modifier,
modifier = modifier.predictiveBack(onBack = onBack),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
@@ -174,6 +173,7 @@ private fun SearchResults(
SearchResultRow(
event = event,
position = positionOf(index, events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
@@ -184,10 +184,12 @@ private fun SearchResults(
private fun SearchResultRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
GroupedRow(
modifier = modifier,
title = event.title,
summary = searchSummary(event),
position = position,

View File

@@ -42,11 +42,13 @@ import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -88,8 +90,12 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
import de.jeanlucmakiola.calendula.ui.common.Position
@@ -288,6 +294,7 @@ private fun AboutCard() {
val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url)
val supportUrl = stringResource(R.string.about_support_url)
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -346,6 +353,19 @@ private fun AboutCard() {
Text(stringResource(R.string.settings_license))
}
}
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { openUrl(context, supportUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_support))
}
}
}
}
@@ -404,6 +424,7 @@ private fun AppearanceScreen(
) {
var showTheme by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
@@ -415,6 +436,12 @@ private fun AppearanceScreen(
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Middle,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (state.dynamicColorAvailable) {
@@ -464,6 +491,16 @@ private fun AppearanceScreen(
onDismiss = { showWeekStart = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
}
@Composable
@@ -655,7 +692,11 @@ private fun NotificationsScreen(
}
},
)
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column {
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
GroupedRow(

View File

@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -17,6 +18,8 @@ data class SettingsUiState(
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether Calendula posts reminder notifications (v1.4). */

View File

@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -72,8 +73,10 @@ class SettingsViewModel @Inject constructor(
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
) { base, defaults, overrides ->
prefs.defaultView,
) { base, defaults, overrides, defaultView ->
base.copy(
defaultView = defaultView,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -115,6 +118,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setWeekStart(pref) }
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}

View File

@@ -80,6 +80,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -247,6 +249,8 @@ private fun WeekContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
@@ -300,7 +304,7 @@ private fun WeekContent(
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition",
) { s ->
when (s) {

View File

@@ -41,6 +41,7 @@ import androidx.glance.text.TextStyle
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.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -185,7 +186,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@@ -199,11 +204,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
.padding(horizontal = 4.dp, vertical = 4.dp)
.clickable(
actionStartActivity(
MainActivity.eventDetailIntent(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
),
),

View File

@@ -49,6 +49,7 @@ import androidx.glance.unit.ColorProvider
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.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable(
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
),
)
HeaderIcon(
@@ -300,8 +303,16 @@ private fun WeekRow(
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current
Box(
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
// Tap a day number to open that day, rooted in the month view so back
// returns to the month grid.
.clickable(
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
),
contentAlignment = Alignment.Center,
) {
Box(
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
@Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
val context = LocalContext.current
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
Box(
modifier = GlanceModifier
.width(width)
.height(LANE_HEIGHT)
.padding(horizontal = 1.dp)
// Tap an event bar to open its detail, rooted in the month view.
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Month,
),
),
),
) {
Box(
modifier = GlanceModifier
.fillMaxSize()

View File

@@ -315,6 +315,7 @@
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">von Jean-Luc Makiola</string>
<string name="settings_about_source">Quellcode</string>
<string name="settings_about_support">Entwicklung unterstützen</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string>

View File

@@ -264,6 +264,7 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<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_week_start">Week starts on</string>
@@ -299,7 +300,7 @@
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_section_about">About</string>
@@ -307,6 +308,7 @@
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula app icon</string>
<string name="settings_report_problem">Report a problem</string>
@@ -317,6 +319,8 @@
<string name="calendars_local_header">Your calendars</string>
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
<string name="calendars_add">Add calendar</string>
<string name="calendars_disable_hint">Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime.</string>
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
<string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage</string>
@@ -377,6 +381,7 @@
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<!-- Crash reporting: a captured report the user can submit, by hand, as a
Gitea issue (the app sends nothing automatically). -->

View File

@@ -170,6 +170,74 @@ class CalendarRepositoryImplTest {
}
}
@Test
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Enabled", calendarId = 1L),
makeEvent(11L, "Disabled", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L))
prefs.setDisabledCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Hidden", calendarId = 2L),
makeEvent(12L, "Disabled", calendarId = 3L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
makeEvent(11L, "B", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
prefs.setDisabledCalendarIds(setOf(2L))
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }

View File

@@ -50,4 +50,34 @@ class CalendarPrefsTest {
}
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
}
@Test
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
}
@Test
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
}
@Test
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(emptySet())
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
}
@Test
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(setOf(2L))
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L))
}
}

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The pure back-stack rules behind [CalendarHost]'s top-level navigation:
* the home view is always the bottom, a lateral move keeps the stack one deep,
* and a date tap drills the day view on top. Verifies the back sequences the
* widget-UX fix promises (e.g. agenda widget → day → back → agenda → back → home).
*/
class ViewBackStackTest {
@Test
fun `a widget whose view is the home view resets to just the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
.containsExactly(CalendarView.Week)
}
@Test
fun `a widget launch resets to its view over the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
.containsExactly(CalendarView.Week, CalendarView.Month)
.inOrder()
}
@Test
fun `pill switches build a visit history that back retraces`() {
// Agenda → Day → Week → Month, each a new view, stacks the full history.
val stack = listOf(CalendarView.Agenda)
.selectView(CalendarView.Day)
.selectView(CalendarView.Week)
.selectView(CalendarView.Month)
assertThat(stack).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
// Back walks the views in reverse, not straight to the home view.
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Week)
}
@Test
fun `selecting an already-visited view collapses the loop back to it`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Agenda)
assertThat(stack).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
}
@Test
fun `selecting the home view collapses to just the home`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Week)
assertThat(stack).containsExactly(CalendarView.Week)
}
@Test
fun `drilling pushes the day view on top`() {
assertThat(listOf(CalendarView.Week, CalendarView.Month).drillToDay())
.containsExactly(CalendarView.Week, CalendarView.Month, CalendarView.Day)
.inOrder()
}
@Test
fun `drilling is a no-op when the day view is already current`() {
val stack = listOf(CalendarView.Week, CalendarView.Day)
assertThat(stack.drillToDay()).isEqualTo(stack)
}
@Test
fun `agenda widget date tap backs out agenda then home`() {
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
val stack = viewBaseStack(CalendarView.Week, CalendarView.Agenda).drillToDay()
assertThat(stack)
.containsExactly(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.inOrder()
// Pressing back pops one level at a time down to the home view.
assertThat(stack.dropLast(1)).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
assertThat(stack.dropLast(2)).containsExactly(CalendarView.Week)
}
@Test
fun `month widget date tap backs out month then home`() {
val stack = viewBaseStack(CalendarView.Week, CalendarView.Month).drillToDay()
assertThat(stack.last()).isEqualTo(CalendarView.Day)
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
assertThat(stack.first()).isEqualTo(CalendarView.Week)
}
@Test
fun `a widget whose view is the home view does not duplicate the home`() {
// Default = Agenda, agenda widget event tap → just the home, no extra layer.
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda)
}
}

24
docs/BUILDING.md Normal file
View File

@@ -0,0 +1,24 @@
# Building from source
Calendula builds with the standard Android Gradle toolchain — no extra setup
beyond the SDK and a JDK.
## Requirements
- **Android SDK 36+**
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
The Gradle wrapper is checked in, so you don't need a system Gradle.
## Common tasks
```bash
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
## Release builds
Release signing and the publishing pipeline are documented separately in
[RELEASING.md](RELEASING.md).

View File

@@ -21,6 +21,7 @@ AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
Donate: https://ko-fi.com/jeanlucmakiola
AutoName: Calendula

View File

@@ -0,0 +1,33 @@
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).

View File

@@ -0,0 +1,14 @@
### Added
- Turn a calendar off across the whole app. Settings → Calendars now has a
switch on every calendar — both your own and synced ones. Switching one off
removes it everywhere: its events disappear from all views and search, and it
drops out of the drawer's hide/show filter, the event-form calendar picker and
the import target picker. Unlike hiding (the quick per-view checkbox in the
drawer), a disabled calendar leaves the app entirely until you turn it back on.
Nothing is deleted and no other app is affected — it's a Calendula-only view
choice — so you can re-enable it any time from the same screen, where disabled
calendars stay listed but dimmed.
- An optional way to support development. Settings → About now has a "Support
development" button that opens Ko-fi in your browser. It's a plain donation
link with no perks attached, and it needs no new permissions — Calendula still
has no internet access of its own and just hands the link to your browser.

View File

@@ -8,3 +8,4 @@ Categories:
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Donate: https://ko-fi.com/jeanlucmakiola