Compare commits

...

34 Commits

Author SHA1 Message Date
3d8e3ca69e chore(release): prepare v2.8.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m5s
Bump versionName to 2.8.0 (versionCode 20800) — the merge of this to main
is what cuts the release. Move the accumulated 2.8.0 work out of
[Unreleased] into a dated CHANGELOG section and regenerate the F-Droid
per-version changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:42:56 +02:00
3b5c0be765 Merge pull request 'feat: full-text event search' (!33) from feat/event-search into release/v2.8.0
Reviewed-on: #33
2026-06-23 09:27:51 +00:00
013efef29e fix(search): clear on reopen and centre messages above the keyboard
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m14s
The search ViewModel is activity-scoped and outlives the overlay, so a reopened
search showed the previous query/results — reset the query when the screen
re-enters (peeking a result keeps it, as the screen stays composed under the
detail). Add imePadding so the idle/empty message re-centres in the area above
the keyboard instead of staying centred on the full page behind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:05:26 +02:00
5457a34282 feat(search): show a recurring result's next occurrence
A recurring master's DTSTART is the series start, which for long-running series
reads as an old date and sorts into the past. For recurring hits (non-empty
RRULE/RDATE), resolve the occurrence nearest to now via the Instances provider —
the soonest upcoming within ~2 years, else the most recent past — and display
and sort by that. Falls back to the series start when no occurrence lies in the
window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:51:28 +02:00
82e93fda4c feat(search): full-text event search from the top bar
A magnifier in each calendar screen's top bar opens a search overlay: type a
query and matching events (title, location or description) appear, nearest-to-
today first; tapping a result opens its detail.

- Data: query the Events table directly with a LIKE selection on title /
  description / location (wildcards escaped), so search is unbounded in time and
  filtered provider-side. New SearchProjection + toSearchResult mapper reuse the
  .ics export's DURATION handling for recurring masters. Hidden calendars are
  filtered out, mirroring instances().
- SearchViewModel debounces the query (250 ms), needs >= 2 chars, and orders
  results upcoming-ascending then past-descending.
- SearchScreen: autofocused inline field in the top bar, GroupedRow results
  reusing the agenda row style, idle/empty states. Hosted as a CalendarHost
  overlay below detail/edit so a tapped result's detail draws on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:42:37 +02:00
871cc34cab Merge pull request 'feat: 'New event' Quick Settings tile' (!32) from feat/qs-tile-new-event into release/v2.8.0
Reviewed-on: #32
2026-06-23 08:18:09 +00:00
5b99da32b0 fix(qs): silence StartActivityAndCollapseDeprecated lint error
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m15s
lintDebug aborts on the deprecated startActivityAndCollapse(Intent) overload in
the pre-34 branch, even though that overload is the only one available below
UpsideDownCake and is reached only there. Suppress the lint issue (and the
compiler deprecation) at the function level since the call is intentional and
version-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:13:33 +02:00
b8a7191fbe fix(nav): bring external new-event/open-date to the front
Some checks failed
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Failing after 3m36s
The create form and Settings/calendar-manager are sibling overlays in one Box,
with Settings drawn above the form. An external 'new event' request (QS tile,
launcher shortcut, widget) set the create state correctly but rendered the form
underneath an open Settings, forcing the user to back out first.

Dismiss the covering overlays (Settings, calendar manager, detail/edit, import)
when handling a Create or OpenDate nav request, so the requested destination is
revealed on top. Fixes the same latent bug for the shortcut and widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:59:44 +02:00
6b0eb48056 Merge pull request 'feat: current-time line in day and week views' (!31) from feat/now-line into release/v2.8.0
Reviewed-on: #31
2026-06-23 07:59:12 +00:00
572a4734ea feat(qs): add a 'New event' Quick Settings tile
A stateless TileService that opens the create-event form on today — the same
action as the launcher 'New event' shortcut and the agenda widget's +. Reuses
MainActivity.openCreateIntent; wrapped in unlockAndRun and using the API 34+
startActivityAndCollapse(PendingIntent) form (deprecated Intent form below 34).
No new permission (BIND_QUICK_SETTINGS_TILE is system-side).

Discoverability: Settings → New event form gains an 'Add Quick Settings tile'
row that fires StatusBarManager.requestAddTileService (API 33+); on older
versions the tile is still addable manually from the system QS editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:52:24 +02:00
7f8a9069c0 feat(views): show a current-time line in day and week views
All checks were successful
CI / ci (pull_request) Successful in 4m21s
A thin primary-coloured indicator (leading dot + line) marks the current
time across today's column in the day and week timelines, positioned on the
same HOUR_HEIGHT scale as the event blocks so it lines up with the grid.

Shared ui/common/NowLine.kt ticks once a minute, re-aligning to each minute
boundary to avoid drift, and only mounts on today's column so a single
coroutine runs. Renders nothing when today isn't in view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:36:40 +02:00
22c4eded96 Merge pull request 'feat: pick a location from the contact picker' (!30) from feat/location-from-contacts into release/v2.8.0
Reviewed-on: #30
2026-06-23 07:31:04 +00:00
9839f9cd38 feat(edit): pick a location from the contact picker
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m19s
Reuse the attendee contact-picker mechanism for the location field: a
Contacts button beside the location input opens the system picker scoped
to postal-address rows, and the chosen contact's formatted address is
dropped into the field (multi-line addresses collapsed to one line).

Same no-permission guarantee as the guest picker — ACTION_PICK grants
temporary read access, so no READ_CONTACTS is required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:22:51 +02:00
c0e27133b4 Merge pull request 'feat: editable event attendees with contact picker' (!29) from feat/attendee-editing into release/v2.8.0
Reviewed-on: #29
2026-06-23 07:12:28 +00:00
a4e0ec3fde feat(edit): add guests from the contact picker
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 9m19s
Close the name-on-manual-add gap. The add-guest row gains a "from
contacts" button that launches the system contact picker (ACTION_PICK on
ContactsContract Email URI); the picked email row is queried for
Email.ADDRESS + DISPLAY_NAME, so a chosen guest gets both email and name
in one tap. No READ_CONTACTS — the result Intent grants temporary read
access to just that row, so Calendula stays no-permission/no-network. The
inline email field remains the quick email-only path. Mark the roadmap
item shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:50:23 +02:00
ac17343747 docs(roadmap): attendee write-side shipped; contact picker is the name path
Mark the attendee-editing write side shipped (b0f34ff) and record the
plan to close the name-on-manual-add gap: a "from contacts" entry via
ACTION_PICK on the contacts Email URI, querying the picked row for
Email.ADDRESS + DISPLAY_NAME — no READ_CONTACTS (the result Intent grants
temporary read access), same no-permission mechanism as the location
address picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:44:51 +02:00
b0f34ff18a feat(edit): make event attendees editable
Attendees were read-only (shown on the detail screen since v0.6) but the
form couldn't write them — the last read-only gap in the event model.
Add a Guests section to the create/edit form:

- Inline grouped list: each guest is a tonal card (avatar, name/email, a
  tappable Required/Optional role chip, remove); the trailing card is an
  inline email field — type an address, press Done, it commits. No dialog,
  matching the app's inline-field input idiom. Manual adds are email-only
  (names come from sync); the InlineTextField gains IME-action support.
- Reminders restyled to the same grouped-list pattern (shared
  GroupedItemCard / AddActionCard), replacing the single-card blob.

Persistence (CalendarDataSource): new guests are written as plain
RELATIONSHIP_ATTENDEE / STATUS_INVITED rows — no fabricated organizer.
On edit, a dirty-checked reconcileAttendees diffs by email: drops removed
guests, inserts new ones, updates only the required/optional flag on kept
rows (preserving response status). Organizer, resources and no-email rows
are never touched. toEditForm carries only editable guests, so attendees
now ride in the edit snapshot and an external guest change trips the
conflict check.

Per the settled invitation decision: Calendula has no INTERNET and never
sends an invitation — it only writes the rows; the backend decides
delivery. The section shows honest, calendar-aware copy ("your account
may email guests when it syncs" on synced calendars, "no one is notified"
on local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:42:37 +02:00
ab8a9e3401 docs(roadmap): settle attendee-invitation behavior (record-only)
Decision (owner, 2026-06-22): attendee editing is record-only on any
writable calendar. Calendula has no INTERNET and never sends an
invitation itself — it only writes Attendees rows. Notification is
decided downstream (local: no one; CalDAV: server iMIP; Google: Google).
Mandatory backend-aware copy makes this honest. No fabricated ORGANIZER;
the optional "send .ics via email app" delegate is deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:05:11 +02:00
92ec718b28 docs(roadmap): sync state and promote attendee editing to Tier 5
Bring the planning docs up to date with reality from the changelog and
recent merges:
- Mark the .ics engine (export + import) shipped in v2.7.0 and local-
  calendar backup done; clear the stale "in progress" tags.
- Note snooze/dismiss merged into release/v2.8.0 alongside Codeberg
  crash reports.
- Record drag-and-drop rescheduling as consciously rejected.
- Promote attendee editing out of the gated bucket into Tier 5 #12
  (read side already shipped in v0.6; only the write side is missing),
  with the sync-adapter invitation caveat to resolve first.
- Refresh STATE.md (was stuck at v2.4) through v2.7.5 and the 2.8.0
  integration branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:00:45 +02:00
4ad1c09f8f feat(detail): show attendee email under the name
The attendee row only used the email as a fallback when the name was
blank, so a guest with both a name and an email never showed the email.
Add it as a supporting line (bodySmall / onSurfaceVariant) beneath the
name, kept off rows whose headline already falls back to the email.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:00:34 +02:00
5e8cb686e9 Merge pull request 'feat(reminders): snooze + dismiss notification actions' (!28) from feat/reminders-snooze into release/v2.8.0
Reviewed-on: #28
2026-06-22 20:19:36 +00:00
62ebd48e3c docs(changelog): note snooze/dismiss reminder actions
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m33s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:13:36 +02:00
c3dbcca989 Merge pull request 'feat(crash): file reports via the public Codeberg tracker' (!27) from feat/crash-report-codeberg into release/v2.8.0
Reviewed-on: #27
2026-06-22 20:09:42 +00:00
37452be3bd feat(crash): file reports via the public Codeberg tracker instead of email
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 3m22s
Now that a public, writable issue tracker exists (the Codeberg mirror at
codeberg.org/jlmakiola/calendula, which the Gitea issue tab links to),
revert the email hand-off (6150ce6) back to the in-app issue-creation flow:
the crash path opens a prefilled issues/new page (with clipboard copy as the
long-report fallback) and the manual "Report a problem" path opens the issue
template chooser.

The email pivot existed only because the personal Gitea has no public issue
creation (reporters hit a login wall). Codeberg lets anyone register and
file, so that reason is gone. Still no INTERNET permission — the user submits
via the browser themselves. URLs point at Codeberg directly so the prefilled
title/body survive (a Gitea external-tracker redirect wouldn't carry query
params).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:03:10 +02:00
006cce96a9 feat(crash): hand off reports by email instead of the Gitea issue tracker
Filing a Gitea issue requires an account on the instance, so anonymous
reporters hit a login wall — Gitea has no anonymous issue creation. Switch
both the crash-report and manual problem-report paths to compose a
pre-addressed email via ACTION_SENDTO (mailto:), which needs no account and
preserves the existing no-INTERNET, user-sends-it-themselves model. The full
report rides in EXTRA_TEXT, so the old URL-length cap and clipboard-paste
fallback are gone (clipboard copy stays as a safety net).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:03:10 +02:00
88d770bebe Merge pull request 'ci(fdroid): guard all three reproducible-build invariants' (!26) from chore/harden-repro-guard into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #26
2026-06-22 19:58:54 +00:00
b6bee55fc6 ci(fdroid): guard all three reproducible-build invariants
All checks were successful
CI / ci (pull_request) Successful in 2m19s
The reproducible-release CI guard only asserted `vcsInfo { include = false }`.
F-Droid review surfaced two more invariants that, if they regress, silently
stall official publishing (fails safe — stuck on an old version):

- no foojay toolchain resolver in any Gradle script (the offline source
  scanner rejects org.gradle.toolchains.foojay-resolver — it can fetch a JDK
  over the network), and
- `dependenciesInfo { includeInApk = false }` (else AGP embeds a "Dependency
  metadata" block, id 0x504b4453, in the APK Signing Block, which the binary
  scanner rejects as an extra signing block).

The script now checks all three, accumulates failures (reports every broken
invariant in one run, not just the first), and exits non-zero if any fails.
Verified positive + one negative per invariant + all-three-broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:27:58 +02:00
7ab277b434 feat(reminders): snooze + dismiss notification actions
Add "Snooze" and "Dismiss" action buttons to reminder notifications.

The app is otherwise pure provider-broadcast (the Etar model): the calendar
provider fires EVENT_REMINDER and we post the notification, then mark the
CalendarAlerts row fired. A snoozed reminder has no provider backing — its
row is already fired — so snooze self-schedules an exact alarm to re-show
the same notification, while primary delivery is left unchanged.

- ReminderActionReceiver (not exported): SNOOZE cancels + schedules a
  re-show, DISMISS cancels, SHOW (the alarm) re-posts so it can be snoozed
  or dismissed again.
- ReminderSnoozeScheduler: setExactAndAllowWhileIdle, with an inexact
  allow-while-idle fallback if exact alarms are revoked (API 31-32).
- ReminderNotifier: two actions + cancel().
- snoozeMinutes pref (default 10) in Settings -> Notifications, OptionPicker
  presets 5/10/15/30/60.
- Manifest: USE_EXACT_ALARM + SCHEDULE_EXACT_ALARM (maxSdk 32) + receiver.
- New ic_notification_snooze/_dismiss drawables, duration plurals, en+de
  strings, snooze-pref tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:21:01 +02:00
d0b6823385 Merge pull request 'build(fdroid): drop AGP dependency-metadata block; release v2.7.5' (#25) from release/v2.7.5 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 4m58s
Renovate / renovate (push) Successful in 1m6s
Reviewed-on: #25
2026-06-21 19:00:43 +00:00
5c80f99179 build(fdroid): drop AGP dependency-metadata block; release v2.7.5
All checks were successful
CI / ci (pull_request) Successful in 5m57s
F-Droid's binary scanner (the fdroiddata `check apk` job) rejects any
"extra signing block". Our release APK carried AGP's dependency-metadata
block (id 0x504b4453, ~8 KB) in the APK Signing Block. Disable it with
`dependenciesInfo { includeInApk = false; includeInBundle = false }`.

The block lives in the signing block, not the zip entries, so removing it
changes no build output — v2.7.5 is functionally identical to 2.7.4 and
still reproduces byte-for-byte. Verified locally: a releaseTest build now
carries only the v2 signature + verity padding (no 0x504b4453 block).

This was the last F-Droid blocker: v2.7.5 now clears vcsInfo (since 2.7.3),
foojay (since 2.7.4) and the dependency-metadata block. Bumps versionName
to 2.7.5 and retargets the official recipe draft at v2.7.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:48:59 +02:00
0875e69c2d Merge pull request 'build(fdroid): drop unused foojay toolchain resolver; release v2.7.4' (#24) from release/v2.7.4 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 8m15s
Reviewed-on: #24
2026-06-21 18:23:46 +00:00
ac572da40a build(fdroid): drop unused foojay toolchain resolver; release v2.7.4
All checks were successful
CI / ci (pull_request) Successful in 8m50s
F-Droid's official build scanner rejects the foojay-resolver-convention
plugin because it can fetch a JDK over the network during the offline,
reproducible build. The plugin was inert here (no toolchain block ever
invoked it), so removing it leaves the app functionally identical to
2.7.3 and still reproducible byte-for-byte from source.

Bumps versionName to 2.7.4 (the release trigger) and retargets the
official fdroiddata recipe draft at v2.7.4, plus two recipe corrections
the fdroiddata CI caught on the v2.7.3 submission (MR !40967):
- Categories: 'Time' (retired in the current taxonomy) -> 'Calendar & Agenda'
- AutoUpdateMode: 'Version v%v' (invalid per schema) -> 'Version'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:09:31 +02:00
f27811d215 Merge pull request 'chore(fdroid): finalize recipe at v2.7.3 + reproducible-release CI guard' (#23) from chore/fdroid-recipe-2730 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #23
2026-06-21 12:59:56 +00:00
36e2199dff chore(fdroid): finalize recipe at v2.7.3 + reproducible-release CI guard
All checks were successful
CI / ci (pull_request) Successful in 8m50s
v2.7.3 is the first release built with `vcsInfo { include = false }` and is
verified reproducible: a clean from-source build matched the published
calendula_v2.7.3.apk byte-for-byte across all 1382 zip entries (only the
signature block differs, which F-Droid copies).

- Point the fdroiddata recipe's Builds entry + CurrentVersion at v2.7.3
  (was the 2.7.2 placeholder, which lacks the fix and would fail verification).
- UpdateCheckMode: Tags ^v[0-9.]+$ — after this one-time submission F-Droid
  auto-tracks new release tags and adds build entries itself; no manual recipe
  edits per release.
- Add scripts/check_reproducible_release.sh + an always-on CI step asserting the
  release build keeps VCS-info disabled, so reproducibility can't silently
  regress and quietly stop official-repo publishing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:28:01 +02:00
46 changed files with 2274 additions and 140 deletions

View File

@@ -30,6 +30,12 @@ jobs:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
# change scope so a regression can't slip through on a "docs-only" PR.
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.

View File

@@ -236,22 +236,34 @@ pass on the existing controls; new toggles ride in with their own features.
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
default reminder **+ per-calendar override**, bundled with battery-exemption
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
four surfaces: single-event share + whole-calendar backup (export),
open-`.ics`→form + whole-calendar restore (import). Closes the
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
sequential branches in one release**: `feat/ics-export` (write side +
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
must re-fire an alarm).
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
four surfaces all shipped: single-event share + whole-calendar backup
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
then `feat/ics-import` (parser, restore, dedup by UID). Import is
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
/ guest lists it can't model. Plans:
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)*
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
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.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
@@ -263,11 +275,13 @@ pass on the existing controls; new toggles ride in with their own features.
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, ICS file import. Pulled in opportunistically, not sequenced.
full-text search. Pulled in opportunistically, not sequenced.
Debatable calls worth a second look: whether **local-calendar backup (#10)**
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
whether drag-drop (#12) jumps ahead given its daily-driver impact.
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).
## Navigation & views
@@ -288,8 +302,6 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
## Event editing & creation
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
dialog) — big-ticket, own slice
- 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
@@ -319,13 +331,11 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
go/no-go gate as the OSM/INTERNET item below.
- Move event to another calendar (copy+delete model with a consequences
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
- **Local-calendar backup / export** *(Tier 4 #10)* — device-only
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
restore-on-import that recreates events into a chosen local calendar. Reuses
the .ics serializer from the single-event share work; the restore path reuses
the import parser. A data-integrity obligation, not a feature.
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0**
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
backup. Settings → Calendars → Export writes every event to a user-chosen
`.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)*
@@ -419,9 +429,11 @@ it directly undermines the feature's premise, so it rides in here.
## Sharing & interop
- Share event as .ics + open/receive .ics into a prefilled create form
(front-runs the import below)
- ICS file import (drag-and-drop) *(was v3.0, optional)*
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
event prefills the create form, many events opens a bulk import (dedup by UID)
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
flow above (single → form, many → bulk import)
## Platform & launchers
@@ -455,9 +467,57 @@ in detail yet:
trade-off is an explicit go/no-go decision before any work starts.
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
if the picker proves clunky.
- **Attendee editing / invites from contacts** — own milestone; writing
`Attendees` rows touches sync-adapter invitation behavior (Google vs
DAVx5 differ).
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
Tier 5 #12, high-importance; the no-permission typed-email path is not an
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
The last read-only gap in the event model: attendees are read & shown on the
detail screen (since v0.6) but the form can't write them. Make guests editable.
- **Read side already done:** `Attendee` domain model + status/relationship/type
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
there.
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
inline grouped list — each guest a tonal card (avatar, name/email, tappable
Required/Optional role chip, remove), the trailing card an inline email field
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
required/optional type updates; organizer/resource/no-email rows untouched).
Reminders restyled to the same grouped-list pattern in passing. Needs only the
existing `WRITE_CALENDAR` — no new permission.
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
field is email-only (the quick path); the add row also has a **"from contacts"
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI`
the system Contacts app returns a URI to the picked email row, queried for
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
email and name in one tap. The result Intent grants temporary read access, so
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
mechanism as the location address picker above. Self/organizer rows stay
non-editable.
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
and Google decides (third-party attendee writes are historically unreliable
there). Editing is allowed on **any writable calendar** — not gated to local.
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
decision): on a synced calendar show "Calendula doesn't send invitations —
your calendar account may email guests when it syncs"; on a local calendar
show "Stored on this device. No one is notified."
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
state to coax a send — it writes the guest list faithfully and leaves
scheduling entirely to the backend.
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
still no INTERNET) was considered and **deferred** — revisit only if users ask
to notify guests explicitly.
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
resource booking — all carry server round-trips or richer sync semantics.
## Consciously rejected
@@ -465,3 +525,5 @@ in detail yet:
- Natural-language quick entry (high effort, locale-fragile; the prefilled
form already covers fast entry)
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.

View File

@@ -1,17 +1,27 @@
# Calendula — Current State
*Last updated: 2026-06-17*
*Last updated: 2026-06-22*
## Status
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
the calendar manager and the navigation drawer) both shipped 2026-06-16;
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
agenda + month widgets, and a "New event" launcher shortcut) shipped
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
interop) arc is now done or in flight:
- v2.4.0 per-event colors (2026-06-17)
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
event" launcher shortcut (2026-06-17)
- v2.6.0 default reminders (global + per-calendar override, all-day default,
battery-exemption row) + system per-app language (2026-06-18)
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
(2026-06-18)
- v2.7.1v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
holds crash reports via the public Codeberg tracker (MR !27) + reminder
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
release-cut.
## Progress
@@ -120,11 +130,48 @@ agenda + month widgets, and a "New event" launcher shortcut) shipped
sync" warning on the picker and in Settings. Color writes flow through
insert / dirty-checked update / occurrence-exception; mapper + form tests.
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
jump-to-date drawer date picker, two home-screen widgets (scrolling
"Upcoming" agenda + month grid), and a "New event" launcher long-press
shortcut
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
separate all-day default + per-calendar override (timed), applied on create
with dirty-flag handling; three pickers + override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link,
no new permission). Plus system per-app language (Android 13+) and an
immediate-effect fix for the in-app language picker
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
`.ics` from the detail screen; back up local calendars (Settings → Calendars
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
create form, many events open a bulk import into a chosen calendar (dedup by
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
a widget R8 keep-rule crash fix
- [x] v2.7.1v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
F-Droid reproducible-build cleanups for the official repo
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
## Next
1. Monitor the F-Droid build/publish for the v2.4.0 tag
2. Decide the "Locations & People" and "remote calendar create/edit"
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
agenda view (strategic, backs a future widget). Full ranked sequence in
`ROADMAP.md` → "Near-term sequence".
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
merge-driven pipeline) once on-device review signs off
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
importance, opened 2026-06-22). Attendees are already read & shown on the
detail screen since v0.6; the gap is the write side — make guests editable
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
persisted by diffing `CalendarContract.Attendees`. No new permission for the
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
copy ("your account may email guests when it syncs" on synced calendars, "no one
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
numbers in month, full-text search, accessibility pass). Drag-and-drop
rescheduling is **rejected**.

View File

@@ -5,7 +5,61 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.8.0] — 2026-06-23
### Added
- Find events fast. A search button in the top bar of every calendar view opens
a search box — type a couple of letters and matching events (by title,
location or description) appear, soonest first with past events below. Tap a
result to open it. Search covers your whole calendar, not just what's on
screen, and skips calendars you've hidden. A recurring event shows its next
occurrence rather than the date the series first started.
- A current-time line in the day and week views. A thin coloured line marks the
present moment across today's column, so you can see at a glance where you are
in the day. It updates every minute and only appears when today is in view.
- Add and remove event guests. The create/edit form now has a Guests section:
add people by email or pick them from your contacts, mark each as required or
optional, and remove them. Calendula never sends invitations itself (it has no
internet access) — it only records the guests; if the event lives on a synced
account, that account may email them when it syncs, and on a local calendar no
one is notified. The form tells you which applies. Picking a guest from
contacts needs no contacts permission.
- Pick a location from your contacts. A contacts button beside the location
field drops a contact's address straight into an event — handy for a meeting
at someone's home or office. Like the guest picker, it needs no contacts
permission.
- A "New event" Quick Settings tile. Add it to your quick settings to jump
straight into the new-event form from anywhere. Settings → New event has a
one-tap button to add the tile (Android 13+); on older versions you can add it
from the quick-settings editor.
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
reminder; snooze hides it and brings it back after a delay you pick in
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
system won't re-post a reminder on its own, so Calendula schedules an exact
alarm to bring a snoozed one back on time.
### Changed
- Event details now show each guest's email beneath their name, instead of only
when no name is available.
- Crash and problem reports now open on the project's public Codeberg tracker,
where anyone can register and file an issue. Nothing is sent automatically —
you still review the report and submit it yourself in the browser.
## [2.7.5] — 2026-06-21
### Changed
- Further build cleanup for the official F-Droid repository: stopped embedding
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
scanner rejects as an extra signing block. No functional or visible changes —
the same app as 2.7.4, just without that Play-oriented metadata blob.
## [2.7.4] — 2026-06-21
### Changed
- Build cleanup that lets Calendula ship in the official F-Droid repository:
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
reproducible build process disallows. No functional or visible changes — this
is the same app as 2.7.3.
## [2.7.3] — 2026-06-21

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 = 20703
versionName = "2.7.3"
versionCode = 20800
versionName = "2.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -92,6 +92,16 @@ android {
compose = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"

View File

@@ -13,6 +13,17 @@
-->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 3132 (user-revocable,
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
-->
<uses-permission
android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -77,6 +88,20 @@
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
bind it; the action mirrors the launcher "New event" shortcut. -->
<service
android:name=".qs.NewEventTileService"
android:exported="true"
android:icon="@drawable/ic_qs_new_event"
android:label="@string/qs_tile_new_event_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
no notification itself — a calendar app must (v1.4, Etar model).
Exported: the broadcast arrives from the provider's process. -->
@@ -91,6 +116,13 @@
</intent-filter>
</receiver>
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
re-show alarm. Not exported: only our own notification buttons and
AlarmManager PendingIntents target it. -->
<receiver
android:name=".data.reminders.ReminderActionReceiver"
android:exported="false" />
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
<receiver
android:name=".widget.agenda.AgendaWidgetReceiver"

View File

@@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -46,6 +47,14 @@ interface CalendarDataSource {
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
/**
* Master/one-off events whose title, description or location contains
* [query] (case-insensitive), across all calendars, newest first. Reads the
* Events table directly so the search is unbounded in time; exception rows
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
*/
fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
@@ -297,6 +306,89 @@ class AndroidCalendarDataSource @Inject constructor(
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun searchEvents(query: String): List<EventInstance> {
ensureObserversRegistered()
val trimmed = query.trim()
if (trimmed.isEmpty()) return emptyList()
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
// itself instead of acting as a wildcard.
val escaped = trimmed
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
val like = "%$escaped%"
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
return resolver.query(
CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS,
selection,
arrayOf(like, like, like),
CalendarContract.Events.DTSTART + " DESC",
)?.use { c ->
val reader = CursorColumnReader(c)
val out = ArrayList<EventInstance>(c.count)
while (c.moveToNext()) {
val base = reader.toSearchResult() ?: continue
// A recurring master's DTSTART is the series start; show its
// nearest occurrence instead so the date is the one the user
// actually cares about (and sorting reflects it).
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
out += if (recurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy(
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
)
} ?: base
} else {
base
}
}
out
} ?: emptyList()
}
/**
* The occurrence of [eventId] nearest to now: the soonest upcoming one
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
* within the same window behind. Null when neither exists (e.g. a series
* that only starts further out than the window) — the caller then keeps the
* series-start DTSTART. Returns (begin, end) epoch millis.
*/
private fun nearestOccurrenceMillis(eventId: Long): Pair<Long, Long>? {
val now = System.currentTimeMillis()
return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
}
private fun occurrenceInWindow(
eventId: Long,
beginMillis: Long,
endMillis: Long,
soonestFirst: Boolean,
): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query(
uri,
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()),
order,
)?.use { c ->
if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
}
}
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
@@ -536,6 +628,10 @@ class AndroidCalendarDataSource @Inject constructor(
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
// Guests are best-effort like reminders: a row that fails to attach is
// logged, not surfaced as a failed create. Calendula never sends an
// invitation — it only writes the rows; the backend decides delivery.
insertAttendees(eventId, form.attendees)
return eventId
}
@@ -565,6 +661,12 @@ class AndroidCalendarDataSource @Inject constructor(
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
// Same untouched-set guard for guests: only reconcile when the form's
// attendees actually changed, so unrelated edits never disturb the
// organizer/resource rows the form doesn't model.
if (updated.attendees.toSet() != original.attendees.toSet()) {
reconcileAttendees(eventId, updated.attendees)
}
}
override fun updateOccurrence(
@@ -587,6 +689,7 @@ class AndroidCalendarDataSource @Inject constructor(
// Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
reconcileAttendees(exceptionId, form.attendees)
return exceptionId
}
@@ -720,6 +823,135 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
private fun EventAttendee.key(): String = email.trim().lowercase()
/** Distinct guests by email; blanks dropped (we key and write on the address). */
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
val seen = HashSet<String>()
return filter { it.email.isNotBlank() && seen.add(it.key()) }
}
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
ContentValues().apply {
put(CalendarContract.Attendees.EVENT_ID, eventId)
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
attendee.name.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
// A plain guest — never the organizer (we don't fabricate scheduling
// identity; the backend owns that). Invited = awaiting their reply.
put(
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
)
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
put(
CalendarContract.Attendees.ATTENDEE_STATUS,
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
)
}
private fun EventAttendee.providerType(): Int = if (optional) {
CalendarContract.Attendees.TYPE_OPTIONAL
} else {
CalendarContract.Attendees.TYPE_REQUIRED
}
/** Best-effort attendee inserts (like reminders); emails are never logged. */
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
attendees.distinctByEmail().forEach { attendee ->
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
}
private data class AttendeeRow(
val id: Long,
val email: String,
val relationship: Int,
val type: Int,
) {
val key: String get() = email.trim().lowercase()
}
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
CalendarContract.Attendees.CONTENT_URI,
arrayOf(
CalendarContract.Attendees._ID,
CalendarContract.Attendees.ATTENDEE_EMAIL,
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.ATTENDEE_TYPE,
),
CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
AttendeeRow(
id = c.getLong(0),
email = c.getString(1).orEmpty(),
relationship = c.getInt(2),
type = c.getInt(3),
),
)
}
}
} ?: emptyList()
/**
* Make the event's *editable* guest rows match [attendees]. Only rows we
* own — has an email, not the organizer, not a resource — are reconciled;
* everything else the backend put there is left untouched. Matched rows keep
* their response status (only the required/optional type is updated if it
* changed); dropped rows are deleted; new guests inserted. Calendula sends
* nothing — the backend decides if anyone is notified on sync.
*/
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
val target = attendees.distinctByEmail()
val targetByKey = target.associateBy { it.key() }
val editable = queryAttendeeRows(eventId).filter {
it.email.isNotBlank() &&
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
it.type != CalendarContract.Attendees.TYPE_RESOURCE
}
val editableKeys = editable.map { it.key }.toSet()
editable.forEach { row ->
val want = targetByKey[row.key]
if (want == null) {
resolver.delete(
CalendarContract.Attendees.CONTENT_URI,
CalendarContract.Attendees._ID + " = ?",
arrayOf(row.id.toString()),
)
} else if (row.type != want.providerType()) {
resolver.update(
CalendarContract.Attendees.CONTENT_URI,
ContentValues().apply {
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
},
CalendarContract.Attendees._ID + " = ?",
arrayOf(row.id.toString()),
)
}
}
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
}
private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv ->
forEach { (column, value) ->
@@ -814,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor(
* together (by account) in the filter sheet and calendar manager.
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
/**
* How far ahead/behind a search looks for a recurring event's nearest
* occurrence (~2 years). Wide enough for everyday series; a series that
* next fires beyond it falls back to its series-start date.
*/
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
}
}

View File

@@ -16,6 +16,13 @@ interface CalendarRepository {
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
/**
* Events whose title, description or location contains [query], with hidden
* calendars removed and newest first. Empty when [query] is blank. Searches
* the whole history/future (see [CalendarDataSource.searchEvents]).
*/
suspend fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]).

View File

@@ -80,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val hidden = prefs.hiddenCalendarIds.first()
dataSource.searchEvents(query)
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) }

View File

@@ -139,6 +139,44 @@ internal object EventExportProjection {
const val IDX_CALENDAR_ID = 13
}
/**
* Master/one-off Events rows matched by a full-text search. Like
* [EventExportProjection] it reads the Events table directly (so the search is
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
* Colour folds the calendar fallback like [InstanceProjection].
*/
internal object SearchProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.EVENT_LOCATION,
// Recurrence markers: a non-empty RRULE or RDATE means the result should
// display its nearest occurrence, not the series-start DTSTART.
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE,
)
const val IDX_ID = 0
const val IDX_CALENDAR_ID = 1
const val IDX_TITLE = 2
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9
const val IDX_RRULE = 10
const val IDX_RDATE = 11
}
internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
* Instances query this reads the series master, so there is no instance id (the
* event id stands in as the list key) and recurring rows carry DURATION instead
* of DTEND — reconstruct the end the same way the `.ics` export does.
*/
internal fun ColumnReader.toSearchResult(): EventInstance? {
val dtStart = getLong(SearchProjection.IDX_DTSTART)
if (dtStart < 0L) return null
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val rawTitle = getString(SearchProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance(
instanceId = eventId,
eventId = eventId,
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
title = title,
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(SearchProjection.IDX_LOCATION),
)
}

View File

@@ -177,6 +177,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
}
/**
* How long the notification "Snooze" action defers a reminder, in minutes.
* Tapping Snooze cancels the notification and re-posts it after this delay
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
* clamped to at least 1.
*/
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
}
suspend fun setSnoozeMinutes(minutes: Int) {
store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) }
}
/**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global
@@ -243,6 +257,9 @@ class SettingsPrefs @Inject constructor(
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
* the internal re-show when a snooze elapses. All three are app-internal
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
* alarm), so the receiver is not exported.
*
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
* already fired, so nothing re-posts it.
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
* it after the user's snooze delay.
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
* or dismiss it again.
*/
@AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var scheduler: ReminderSnoozeScheduler
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onReceive(context: Context, intent: Intent) {
val alert = alertFrom(intent) ?: return
when (intent.action) {
ACTION_DISMISS -> notifier.cancel(alert)
ACTION_SNOOZE -> {
// Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work.
notifier.cancel(alert)
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
} finally {
pendingResult.finish()
}
}
}
ACTION_SHOW -> {
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
} finally {
pendingResult.finish()
}
}
}
}
}
companion object {
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
private const val EXTRA_ALERT_ID = "alert_id"
private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_BEGIN = "begin"
private const val EXTRA_END = "end"
private const val EXTRA_TITLE = "title"
private const val EXTRA_LOCATION = "location"
private const val EXTRA_ALL_DAY = "all_day"
/** An explicit intent to this receiver carrying [alert] as extras. */
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId)
putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_BEGIN, alert.beginMillis)
putExtra(EXTRA_END, alert.endMillis)
putExtra(EXTRA_TITLE, alert.title)
putExtra(EXTRA_LOCATION, alert.location)
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
}
/**
* A stable request code per (alert, action) so the three PendingIntents
* of one notification stay distinct and don't clobber each other.
*/
fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) {
ACTION_SNOOZE -> 1
ACTION_DISMISS -> 2
ACTION_SHOW -> 3
else -> 0
}
return alert.alertId.toInt() * 8 + actionOffset
}
private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
endMillis = intent.getLongExtra(EXTRA_END, 0L),
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
location = intent.getStringExtra(EXTRA_LOCATION),
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
)
}
}
}

View File

@@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor(
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(detailIntent(alert))
.addAction(
R.drawable.ic_notification_snooze,
context.getString(R.string.reminder_action_snooze),
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
)
.addAction(
R.drawable.ic_notification_dismiss,
context.getString(R.string.reminder_action_dismiss),
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
)
.build()
try {
NotificationManagerCompat.from(context)
@@ -71,6 +81,19 @@ class ReminderNotifier @Inject constructor(
}
}
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
fun cancel(alert: ReminderAlert) {
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
}
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, action),
ReminderActionReceiver.intent(context, action, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
/* requestCode = */ alert.alertId.toInt(),

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
*
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
* back to an inexact allow-while-idle alarm only if the OS withholds the
* exact-alarm capability (API 3132 where the user revoked it).
*/
@Singleton
class ReminderSnoozeScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
if (canScheduleExact(alarmManager)) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 3132): an inexact wake is the honest
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
* (Calendula is a calendar app), user-revocable on 3132.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
}

View File

@@ -41,6 +41,27 @@ data class EventForm(
*/
val colorKey: String? = null,
val color: Int? = null,
/**
* Guests the user has added/kept on the event. Calendula only writes these
* `Attendees` rows — it has no INTERNET and never sends an invitation
* itself; whether a guest is notified is decided downstream by the
* calendar's backend (local: no one; CalDAV/Google: the server/account).
* Read-only rows the form doesn't model (the organizer, resources) are
* preserved by the data layer, not carried here.
*/
val attendees: List<EventAttendee> = emptyList(),
)
/**
* One editable guest: the user controls the [email] (the identity we dedup and
* write on), an optional display [name], and whether they're [optional] rather
* than required. Response status and the organizer/resource distinction are not
* user-editable, so they aren't modelled here.
*/
data class EventAttendee(
val email: String,
val name: String = "",
val optional: Boolean = false,
)
/**
@@ -55,6 +76,7 @@ enum class EventFormField {
Availability,
Visibility,
Color,
Attendees,
}
enum class EventFormProblem {
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
// calendar's colour.
colorKey = eventColorKey,
color = eventColor,
// Only editable guests ride in the form: drop the organizer and
// resource rows (not user-editable) and any without an email (we key
// edits and dedup on the address). The data layer preserves the rows we
// don't carry here, so they're never clobbered on save.
attendees = attendees
.filter {
it.relationship != AttendeeRelationship.Organizer &&
it.type != AttendeeType.Resource
}
.mapNotNull { a ->
a.email?.takeIf { it.isNotBlank() }?.let { email ->
EventAttendee(
email = email,
name = a.name,
optional = a.type == AttendeeType.Optional,
)
}
},
)
}
@@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
* while the form was open. The raw row times ride along because
* [toEditForm] derives the form's times from the *tapped occurrence*, so
* re-deriving with the same occurrence would mask an externally moved
* event. Not covered (the form can't write them, and the dirty-checked
* write can't clobber them): attendees, status, the user's own response,
* reminder methods, and a recurring event's duration.
* event. Guests are covered (the form writes editable attendees), so an
* external attendee change now also trips the conflict check. Still not
* covered: status, the user's own response, reminder methods, the
* organizer/resource rows, and a recurring event's duration.
*/
data class EditSnapshot(
val form: EventForm,
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (availability != Availability.Busy) add(EventFormField.Availability)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
if (colorKey != null || color != null) add(EventFormField.Color)
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
}
fun EventForm.problems(): Set<EventFormProblem> = buildSet {

View File

@@ -0,0 +1,43 @@
package de.jeanlucmakiola.calendula.qs
import android.app.PendingIntent
import android.os.Build
import android.service.quicksettings.TileService
import de.jeanlucmakiola.calendula.MainActivity
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Quick Settings tile: tapping it opens the create-event form on today — the
* same action as the launcher "New event" shortcut and the agenda widget's "+".
* A stateless action tile, so there is no on/off state to keep in sync.
*/
class NewEventTileService : TileService() {
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
// the only form available below UpsideDownCake, and is reached only there.
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
override fun onClick() {
super.onClick()
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
val intent = MainActivity.openCreateIntent(this, today)
// Launch only once the device is unlocked: creating an event behind the
// keyguard makes no sense, and the shade can't start an activity over a
// locked screen anyway.
unlockAndRun {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pending = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pending)
} else {
startActivityAndCollapse(intent)
}
}
}
}

View File

@@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
@@ -97,6 +98,12 @@ fun CalendarHost(
var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true }
// Full-text search — its own overlay, opened from each calendar screen's
// top bar. Sits below the detail/edit overlays so tapping a result reveals
// the detail on top and backing out returns to the results.
var showSearch by rememberSaveable { mutableStateOf(false) }
val onOpenSearch = { showSearch = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
@@ -137,16 +144,37 @@ fun CalendarHost(
}
}
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
// top instead of underneath whatever the user had open.
fun dismissCoveringOverlays() {
showSettings = false
showCalendars = false
detailKey = null
editKey = null
importUri = null
importForm = null
}
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
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.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
view = CalendarView.Day
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay
// sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them.
dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
heldCreateIso = iso
@@ -168,6 +196,7 @@ fun CalendarHost(
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
@@ -175,6 +204,7 @@ fun CalendarHost(
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
@@ -183,6 +213,7 @@ fun CalendarHost(
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
@@ -190,10 +221,24 @@ fun CalendarHost(
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
}
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SearchScreen(
onBack = { showSearch = false },
onEventClick = onEventClick,
)
}
// Prefer the live key; fall back to the held one only while sliding out.
val activeKey = detailKey ?: heldKey
AnimatedVisibility(

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -75,6 +76,7 @@ fun AgendaScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
@@ -119,6 +121,7 @@ fun AgendaScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -274,6 +277,7 @@ private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -292,6 +296,12 @@ private fun AgendaTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -36,6 +38,9 @@ fun InlineTextField(
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
imeAction: ImeAction = ImeAction.Default,
/** Invoked when the IME action key (e.g. Done) is pressed. */
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
@@ -53,7 +58,11 @@ fun InlineTextField(
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {

View File

@@ -0,0 +1,90 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
private val DotSize = 8.dp
private val LineThickness = 2.dp
/**
* Current wall-clock instant that updates once a minute, re-aligning to each
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
* the middle of a minute. Drives the "now" line in the day/week grids.
*/
@Composable
private fun rememberCurrentMinute(): State<Instant> {
val instant = remember { mutableStateOf(Clock.System.now()) }
LaunchedEffect(Unit) {
while (true) {
val now = Clock.System.now()
instant.value = now
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
}
}
return instant
}
/**
* A thin "current time" indicator — a leading dot plus a line — drawn across a
* day column. Positioned on the same [hourHeight] scale the event blocks use so
* it lines up with the grid, and refreshed each minute. Renders nothing unless
* the wall clock is on [date]; callers mount it only for the column showing
* today, so the per-minute tick runs on a single column.
*/
@Composable
fun NowLine(
date: LocalDate,
hourHeight: Dp,
modifier: Modifier = Modifier,
) {
val now by rememberCurrentMinute()
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
if (local.date != date) return
val minutes = local.hour * 60 + local.minute
val top = hourHeight * (minutes / 60f)
val color = MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.fillMaxWidth()
.height(DotSize)
.offset(y = top - DotSize / 2),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(LineThickness)
.background(color),
)
// The dot anchors the line to the gutter edge, mirroring the standard
// calendar "now" marker; drawn after the line so it sits on top.
Box(
modifier = Modifier
.size(DotSize)
.background(color, CircleShape),
)
}
}

View File

@@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -71,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.next
@@ -105,6 +107,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
@@ -188,6 +191,7 @@ fun DayScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -340,6 +344,7 @@ private fun DayTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -358,6 +363,12 @@ private fun DayTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -496,6 +507,7 @@ private fun Timeline(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -512,6 +524,7 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -556,6 +569,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}

View File

@@ -590,10 +590,22 @@ private fun AttendeeRow(attendee: Attendee) {
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
val hasName = attendee.name.isNotBlank()
Text(
text = attendee.name.ifBlank { attendee.email.orEmpty() },
text = if (hasName) attendee.name else attendee.email.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
)
// Email as supporting text — only when the name is the headline, so we
// don't repeat it on rows that already fall back to the email above.
if (hasName) {
attendee.email?.takeIf { it.isNotBlank() }?.let { email ->
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
attendeeRoleLabel(attendee)?.let { roleRes ->
Text(
text = stringResource(roleRes),

View File

@@ -1,7 +1,13 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
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
@@ -30,7 +36,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
@@ -38,10 +43,14 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
@@ -49,11 +58,11 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
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
@@ -82,13 +91,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
@@ -97,6 +110,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -115,6 +129,8 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
@@ -429,6 +445,37 @@ private fun EventEditContent(
var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
// Pick a guest from contacts: a one-shot system picker returning the chosen
// email row (email + display name). No READ_CONTACTS — the result Intent
// grants temporary read access, so Calendula stays no-network/no-permission.
val context = LocalContext.current
val pickContact = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
readContactEmail(context, uri)?.let { (email, name) ->
viewModel.addAttendee(email, name)
}
}
}
}
// Pick a postal address from contacts: the same one-shot picker, scoped to
// address rows, dropping the chosen contact's formatted address into the
// location field. Same no-permission guarantee as the guest picker above.
val pickContactAddress = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
readContactAddress(context, uri)?.let { address ->
viewModel.setLocation(address)
}
}
}
}
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour.
@@ -560,11 +607,39 @@ private fun EventEditContent(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
) {
InlineField(
value = form.location,
onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location),
)
Row(verticalAlignment = Alignment.CenterVertically) {
InlineField(
value = form.location,
onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location),
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp),
)
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
}
@@ -587,33 +662,24 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
Spacer(Modifier.height(gap))
// Reminders stack one per row (remove on the right edge); the
// add chip closes the card at the bottom. The card icon centres
// on the first row, whatever that is.
EditCard(
icon = Icons.Default.Notifications,
iconContentDescription = stringResource(R.string.event_detail_reminders),
header = {
when (val first = form.reminders.firstOrNull()) {
null -> AddReminderChip(onClick = { showReminderPicker = true })
else -> ReminderRow(
label = reminderLabel(first),
onRemove = { viewModel.removeReminder(first) },
)
}
},
) {
form.reminders.drop(1).forEach { minutes ->
ReminderRow(
label = reminderLabel(minutes),
onRemove = { viewModel.removeReminder(minutes) },
)
}
if (form.reminders.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
AddReminderChip(onClick = { showReminderPicker = true })
}
// A grouped list like guests: each reminder is its own tonal card
// with connected corners, the last row being "Add reminder".
val reminders = form.reminders
val rows = reminders.size + 1
reminders.forEachIndexed { index, minutes ->
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
title = reminderLabel(minutes),
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
onRemove = { viewModel.removeReminder(minutes) },
)
}
AddActionCard(
label = stringResource(R.string.event_edit_add_reminder),
position = positionOf(reminders.size, rows),
onClick = { showReminderPicker = true },
)
}
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
@@ -776,6 +842,52 @@ private fun EventEditContent(
}
}
OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) {
Spacer(Modifier.height(gap))
// Guests are an inline grouped list: each is a tonal card with a
// role toggle + remove, and the trailing card is an inline email
// field — type an address, press Done, it commits. Below sits an
// honest note — Calendula never sends invitations, so whether a
// guest is notified is the calendar backend's call.
val guests = form.attendees
val rows = guests.size + 1
guests.forEachIndexed { index, attendee ->
GuestCard(
attendee = attendee,
position = positionOf(index, rows),
onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) },
onRemove = { viewModel.removeAttendee(attendee) },
)
}
AddGuestInlineCard(
position = positionOf(guests.size, rows),
onAdd = { viewModel.addAttendee(it) },
onPickContact = {
runCatching {
pickContact.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
),
)
}
},
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(
if (isLocalCalendar) {
R.string.event_edit_attendees_note_local
} else {
R.string.event_edit_attendees_note_synced
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
TextButton(
@@ -840,6 +952,7 @@ private fun EventEditContent(
)
}
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
@@ -1262,45 +1375,274 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
}
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
@Composable
private fun ReminderRow(label: String, onRemove: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
)
IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) {
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
/** The 2dp gap that visually separates grouped cards (none after the last). */
private fun groupedGap(position: Position): Modifier = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
/**
* One entry in a grouped form list (a guest, a reminder): a leading [icon],
* a [title] with optional [supporting] line, and a remove button — all on a
* tonal card whose corners come from its [position] in the run.
*/
@Composable
private fun GroupedItemCard(
position: Position,
icon: ImageVector,
title: String,
removeContentDescription: String,
onRemove: () -> Unit,
supporting: String? = null,
titleMaxLines: Int = Int.MAX_VALUE,
trailing: @Composable (() -> Unit)? = null,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = groupedShape(position),
modifier = Modifier
.fillMaxWidth()
.then(groupedGap(position)),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_remove_reminder),
modifier = Modifier.size(18.dp),
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
if (supporting != null) {
Text(
text = supporting,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (trailing != null) {
Spacer(Modifier.width(8.dp))
trailing()
}
IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = removeContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
/**
* One guest as a grouped-list card: avatar glyph, name/email, a tappable
* Required/Optional role chip, and remove. The email shows as the supporting
* line when a name is present, otherwise as the title.
*/
@Composable
private fun GuestCard(
attendee: EventAttendee,
position: Position,
onToggleOptional: (Boolean) -> Unit,
onRemove: () -> Unit,
) {
val hasName = attendee.name.isNotBlank()
GroupedItemCard(
position = position,
icon = Icons.Default.Person,
title = if (hasName) attendee.name else attendee.email,
supporting = attendee.email.takeIf { hasName },
titleMaxLines = 1,
removeContentDescription = stringResource(R.string.event_edit_remove_guest),
onRemove = onRemove,
trailing = {
FilterChip(
selected = attendee.optional,
onClick = { onToggleOptional(!attendee.optional) },
label = {
Text(
stringResource(
if (attendee.optional) {
R.string.event_edit_attendee_optional
} else {
R.string.event_edit_attendee_required
},
),
)
},
)
},
)
}
/** The trailing "Add …" action card of a grouped form list (opens a picker). */
@Composable
private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) {
Surface(
onClick = onClick,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = groupedShape(position),
modifier = Modifier
.fillMaxWidth()
.then(groupedGap(position)),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
/**
* The trailing card of the guests list: an inline email field plus a "from
* contacts" button. Type an address and press Done (or tap +) to commit a guest;
* or pick from contacts to fill email + name at once. The field clears after,
* ready for the next. Matches the form's inline-field input style — no dialog.
*/
@Composable
private fun AddReminderChip(onClick: () -> Unit) {
AssistChip(
onClick = onClick,
label = { Text(stringResource(R.string.event_edit_add)) },
leadingIcon = {
private fun AddGuestInlineCard(
position: Position,
onAdd: (String) -> Unit,
onPickContact: () -> Unit,
) {
var text by rememberSaveable { mutableStateOf("") }
val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() }
fun commit() {
if (valid) {
onAdd(text.trim())
text = ""
}
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = groupedShape(position),
modifier = Modifier
.fillMaxWidth()
.then(groupedGap(position)),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
) {
Icon(
imageVector = Icons.Default.Add,
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
},
border = null,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.width(16.dp))
InlineTextField(
value = text,
onValueChange = { text = it },
placeholder = stringResource(R.string.event_edit_add_guest_hint),
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done,
onImeAction = { commit() },
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
)
if (valid) {
IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_add_guest),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* Read the email + display name from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
*/
private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>? =
context.contentResolver.query(
uri,
arrayOf(
ContactsContract.CommonDataKinds.Email.ADDRESS,
ContactsContract.Contacts.DISPLAY_NAME,
),
null, null, null,
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val email = cursor.getString(0).orEmpty().trim()
val name = cursor.getString(1).orEmpty().trim()
email.takeIf { it.isNotEmpty() }?.let { it to name }
}
/**
* Read the formatted postal address from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
* The provider's formatted address is multi-line; collapse it to one line so
* it sits cleanly in the single-line location field.
*/
private fun readContactAddress(context: Context, uri: Uri): String? =
context.contentResolver.query(
uri,
arrayOf(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS),
null, null, null,
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
cursor.getString(0).orEmpty()
.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(", ")
.takeIf { it.isNotEmpty() }
}
private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
@@ -1309,6 +1651,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
@@ -1319,6 +1662,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette
EventFormField.Attendees -> Icons.Default.People
}
/**

View File

@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -353,6 +354,33 @@ class EventEditViewModel @Inject constructor(
update { it.copy(reminders = it.reminders - minutes) }
}
/**
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
* address just refreshes it (no duplicate row). A blank email is ignored —
* the email is what we write and dedup on.
*/
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
val trimmed = email.trim()
if (trimmed.isEmpty()) return
update { form ->
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
form.copy(attendees = rest + attendee)
}
}
fun removeAttendee(attendee: EventAttendee) =
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
/** Flip a guest between required and optional (the per-row role toggle). */
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
form.copy(
attendees = form.attendees.map { a ->
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
},
)
}
/** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -86,6 +87,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
@@ -161,6 +163,7 @@ fun MonthScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -261,6 +264,7 @@ private fun MonthTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -279,6 +283,12 @@ private fun MonthTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,

View File

@@ -0,0 +1,257 @@
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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.Instant as JavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Full-text event search (top-bar entry). Type a query → matching events
* (title / location / description) across the whole calendar, newest-relevant
* first; tap a result to open its detail. A full-screen overlay hosted by
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
// Each fresh open starts blank and straight into typing. The ViewModel is
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
// resets a previous search. Peeking a result doesn't re-run this (the screen
// stays composed under the detail), so backing out keeps the query.
LaunchedEffect(Unit) {
viewModel.setQuery("")
focusRequester.requestFocus()
keyboard?.show()
}
BackHandler(onBack = onBack)
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
title = {
InlineTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = stringResource(R.string.search_hint),
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.search_back),
)
}
},
actions = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
// imePadding shrinks the content by the keyboard, so the centered
// idle/empty message re-centres in the space above it (and the results
// list lifts clear of the keyboard too).
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
when (val s = state) {
SearchUiState.Idle -> SearchMessage(
icon = null,
text = stringResource(R.string.search_idle_hint),
)
is SearchUiState.Empty -> SearchMessage(
icon = Icons.Default.SearchOff,
text = stringResource(R.string.search_empty, s.query),
)
is SearchUiState.Results -> SearchResults(
events = s.events,
onEventClick = onEventClick,
)
}
}
}
}
@Composable
private fun SearchResults(
events: List<EventInstance>,
onEventClick: (EventInstance) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
) {
itemsIndexed(
items = events,
key = { _, event -> event.eventId },
) { index, event ->
SearchResultRow(
event = event,
position = positionOf(index, events.size),
onClick = { onEventClick(event) },
)
}
}
}
@Composable
private fun SearchResultRow(
event: EventInstance,
position: Position,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
GroupedRow(
title = event.title,
summary = searchSummary(event),
position = position,
minHeight = 64.dp,
leading = {
Box(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
)
},
onClick = onClick,
)
}
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
@Composable
private fun searchSummary(event: EventInstance): String {
val locale = currentLocale()
val zone = remember { ZoneId.systemDefault() }
val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
}
val dateText = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}.format(start)
val timeText = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
remember(locale) {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}.format(start)
}
val base = "$dateText · $timeText"
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
}
@Composable
private fun SearchMessage(
icon: ImageVector?,
text: String,
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -0,0 +1,77 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Clock
import javax.inject.Inject
/** Shortest query that triggers a search; one character matches almost everything. */
private const val MIN_QUERY_LENGTH = 2
sealed interface SearchUiState {
/** No query yet, or one too short to search — show the prompt. */
data object Idle : SearchUiState
/** A query ran but matched nothing. */
data class Empty(val query: String) : SearchUiState
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
data class Results(val events: List<EventInstance>) : SearchUiState
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val state: StateFlow<SearchUiState> = _query
.debounce(250L)
.map { it.trim() }
.distinctUntilChanged()
.mapLatest { q ->
if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle
} else {
val results = repository.searchEvents(q)
if (results.isEmpty()) SearchUiState.Empty(q)
else SearchUiState.Results(sortNearestFirst(results))
}
}
.catch { emit(SearchUiState.Idle) }
.flowOn(io)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
fun setQuery(value: String) {
_query.value = value
}
/** Soonest upcoming (and ongoing) first, then the most recent past. */
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
val now = Clock.System.now()
val (upcoming, past) = events.partition { it.end >= now }
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
}
}

View File

@@ -1,12 +1,16 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.app.StatusBarManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -63,6 +67,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -79,6 +84,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
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
@@ -513,9 +519,39 @@ private fun EventFormScreen(
)
},
)
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
// the QS editor, so the row simply doesn't appear there.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Spacer(Modifier.height(24.dp))
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = Position.Alone,
onClick = { requestAddQsTile(context) },
)
}
}
}
/**
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
* shows its own confirmation dialog and handles the already-added case, so no
* result handling is needed here.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun requestAddQsTile(context: Context) {
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
statusBar.requestAddTileService(
ComponentName(context, NewEventTileService::class.java),
context.getString(R.string.qs_tile_new_event_label),
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
@@ -546,6 +582,7 @@ private fun NotificationsScreen(
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
@@ -666,8 +703,27 @@ private fun NotificationsScreen(
},
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
position = Position.Alone,
onClick = { showSnooze = true },
)
}
if (showSnooze) {
OptionPicker(
title = stringResource(R.string.settings_snooze_duration),
options = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },
onDismiss = { showSnooze = false },
)
}
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
@@ -792,6 +848,18 @@ private fun openBatteryOptimizationSettings(context: Context) {
*/
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
@Composable
private fun snoozeDurationLabel(minutes: Int): String =
if (minutes % 60 == 0) {
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
} else {
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
}
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
val time = Calendar.getInstance().apply {
@@ -872,6 +940,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
@Composable

View File

@@ -30,6 +30,8 @@ data class SettingsUiState(
val defaultAllDayReminderMinutes: Int? = null,
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (null value = no

View File

@@ -59,8 +59,11 @@ class SettingsViewModel @Inject constructor(
prefs.defaultReminderMinutes,
prefs.defaultAllDayReminderMinutes,
prefs.allDayReminderTimeMinutes,
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
prefs.snoozeMinutes,
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
ReminderDefaults(
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
)
},
combine(
prefs.perCalendarReminderOverride,
@@ -75,6 +78,7 @@ class SettingsViewModel @Inject constructor(
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
allDayReminderTimeMinutes = defaults.allDayReminderTime,
snoozeMinutes = defaults.snoozeMinutes,
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
@@ -90,6 +94,7 @@ class SettingsViewModel @Inject constructor(
val defaultReminder: Int?,
val allDayReminder: Int?,
val allDayReminderTime: Int,
val snoozeMinutes: Int,
)
private data class ReminderOverrides(
@@ -130,6 +135,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
}
fun setSnoozeMinutes(minutes: Int) {
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
}
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -76,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -113,6 +115,7 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
@@ -193,6 +196,7 @@ fun WeekScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -351,6 +355,7 @@ private fun WeekTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -369,6 +374,12 @@ private fun WeekTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -610,6 +621,7 @@ private fun Timeline(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -628,6 +640,7 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -671,6 +684,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome notification-action mark: Material "close" glyph. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome notification-action mark: Material "snooze" glyph. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
</vector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Quick Settings tile icon ("New event"): a flat calendar+plus glyph. QS tile
icons are tinted by the system from their alpha, so this is a bare white
glyph on a transparent background (no brand circle, unlike the launcher
shortcut icon). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
</vector>

View File

@@ -77,6 +77,16 @@
<string name="event_edit_add">Hinzufügen</string>
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
<string name="event_edit_attendees">Gäste</string>
<string name="event_edit_add_guest">Gast hinzufügen</string>
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
<string name="event_edit_location_from_contacts">Adresse aus Kontakten wählen</string>
<string name="event_edit_remove_guest">Gast entfernen</string>
<string name="event_edit_attendee_required">Erforderlich</string>
<string name="event_edit_attendee_optional">Optional</string>
<string name="event_edit_attendees_note_synced">Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail.</string>
<string name="event_edit_attendees_note_local">Auf diesem Gerät gespeichert. Niemand wird benachrichtigt.</string>
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
<string name="reminder_unit_minutes">Minuten</string>
<string name="reminder_unit_hours">Stunden</string>
@@ -177,6 +187,15 @@
<item quantity="one">%d Woche vorher</item>
<item quantity="other">%d Wochen vorher</item>
</plurals>
<!-- Reine Zeitdauern (ohne „vorher"), z. B. die Schlummerdauer. -->
<plurals name="duration_minutes">
<item quantity="one">%d Minute</item>
<item quantity="other">%d Minuten</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
</plurals>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
@@ -194,6 +213,8 @@
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
<string name="reminder_onboarding_skip_button">Später</string>
<string name="reminder_action_snooze">Schlummern</string>
<string name="reminder_action_dismiss">Verwerfen</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
@@ -212,6 +233,14 @@
<string name="agenda_empty_title">Nichts geplant</string>
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
<!-- Terminsuche -->
<string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string>
<string name="search_back">Zurück</string>
<string name="search_clear">Löschen</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets -->
<string name="widget_agenda_title">Anstehend</string>
<string name="widget_agenda_label">Calendula Agenda</string>
@@ -227,6 +256,11 @@
<string name="shortcut_new_event_short">Neuer Termin</string>
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
<!-- Schnelleinstellungen-Kachel -->
<string name="qs_tile_new_event_label">Neuer Termin</string>
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>
@@ -265,6 +299,7 @@
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
<string name="settings_snooze_duration">Schlummerdauer</string>
<string name="settings_section_calendars">Kalender</string>
<string name="settings_manage_calendars">Kalender verwalten</string>
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>

View File

@@ -78,6 +78,16 @@
<string name="event_edit_add">Add</string>
<string name="event_edit_add_reminder">Add reminder</string>
<string name="event_edit_remove_reminder">Remove reminder</string>
<string name="event_edit_attendees">Guests</string>
<string name="event_edit_add_guest">Add guest</string>
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
<string name="event_edit_location_from_contacts">Pick address from contacts</string>
<string name="event_edit_remove_guest">Remove guest</string>
<string name="event_edit_attendee_required">Required</string>
<string name="event_edit_attendee_optional">Optional</string>
<string name="event_edit_attendees_note_synced">Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs.</string>
<string name="event_edit_attendees_note_local">Stored on this device. No one is notified.</string>
<string name="event_edit_reminder_custom">Custom</string>
<string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">hours</string>
@@ -178,6 +188,15 @@
<item quantity="one">%d week before</item>
<item quantity="other">%d weeks before</item>
</plurals>
<!-- Plain durations (no "before"), e.g. the snooze delay. -->
<plurals name="duration_minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</plurals>
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
@@ -195,6 +214,8 @@
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
<string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string>
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
@@ -213,6 +234,14 @@
<string name="agenda_empty_title">Nothing scheduled</string>
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
<!-- Event search -->
<string name="search_action">Search</string>
<string name="search_hint">Search events</string>
<string name="search_back">Back</string>
<string name="search_clear">Clear</string>
<string name="search_idle_hint">Search your events by title, location or notes.</string>
<string name="search_empty">No events match “%1$s”.</string>
<!-- Home-screen widgets -->
<string name="widget_agenda_title">Upcoming</string>
<string name="widget_agenda_label">Calendula agenda</string>
@@ -262,6 +291,7 @@
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
<string name="settings_snooze_duration">Snooze duration</string>
<string name="settings_section_calendars">Calendars</string>
<string name="settings_manage_calendars">Manage calendars</string>
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
@@ -340,6 +370,11 @@
<string name="shortcut_new_event_short">New event</string>
<string name="shortcut_new_event_long">Create a new event</string>
<!-- Quick Settings tile -->
<string name="qs_tile_new_event_label">New event</string>
<string name="settings_qs_tile">Add Quick Settings tile</string>
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
<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>
@@ -355,6 +390,6 @@
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new</string>
<string name="report_issue_choose_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
</resources>

View File

@@ -16,6 +16,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
@@ -51,6 +52,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun calendars(): List<CalendarSource> = calendarsResult
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)

View File

@@ -233,6 +233,23 @@ class SettingsPrefsTest {
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
}
@Test
fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.snoozeMinutes.first()).isEqualTo(10)
}
@Test
fun `snooze duration round-trips and clamps to at least one minute`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSnoozeMinutes(30)
assertThat(prefs.snoozeMinutes.first()).isEqualTo(30)
prefs.setSnoozeMinutes(0)
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)

View File

@@ -211,12 +211,55 @@ class EventFormTest {
}
@Test
fun `changes the form cannot write do not fake a conflict`() {
fun `a reminder-method change the form cannot write does not fake a conflict`() {
// The form carries reminder minutes only, not the method — so a method
// flip the form can't express must not read as an external change.
val loaded = detail(reminders = listOf(Reminder(10, ReminderMethod.Alert)))
.toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(reminders = listOf(Reminder(10, ReminderMethod.Email)))
.toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isEqualTo(loaded)
}
@Test
fun `an external guest change now trips the conflict check`() {
// Attendees are writable now, so they ride in the snapshot: adding a
// guest externally is a real conflict, not invisible.
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction)),
).toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isEqualTo(loaded)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `toEditForm carries editable guests with name email and optional role`() {
val prefilled = detail(
attendees = listOf(
Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted, type = AttendeeType.Required),
Attendee("", "bob@example.org", AttendeeStatus.NeedsAction, type = AttendeeType.Optional),
),
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.attendees).containsExactly(
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
EventAttendee(email = "bob@example.org", name = "", optional = true),
).inOrder()
assertThat(prefilled.populatedFields()).contains(EventFormField.Attendees)
}
@Test
fun `toEditForm drops the organizer, resources and guests without an email`() {
val prefilled = detail(
attendees = listOf(
Attendee("Org", "org@example.org", AttendeeStatus.Accepted, AttendeeRelationship.Organizer),
Attendee("Room A", "room@example.org", AttendeeStatus.Accepted, type = AttendeeType.Resource),
Attendee("No address", null, AttendeeStatus.NeedsAction),
Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction),
),
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.attendees).containsExactly(
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
)
}
@Test

View File

@@ -15,7 +15,7 @@
# ---------------------------------------------------------------------------
Categories:
- Time
- Calendar & Agenda
License: MIT
AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
@@ -27,16 +27,20 @@ AutoName: Calendula
RepoType: git
Repo: https://gitea.jeanlucmakiola.de/makiolaj/calendula.git
# IMPORTANT: the first build entry MUST target the first release that contains
# `vcsInfo { include = false }` (added on branch chore/release-verification-process).
# v2.7.1 and earlier embed META-INF/version-control-info.textproto, whose
# env-dependent git metadata breaks reproducibility — they will NOT verify.
# Update the versionName/versionCode/commit/Binaries below once that release is
# tagged (e.g. v2.7.2 -> versionCode 20702). Values below are placeholders.
# First build entry = v2.7.5, the first release that clears ALL three F-Droid
# blockers: (1) AGP VCS-info disabled (`vcsInfo { include = false }`, since
# v2.7.3); (2) the unused Gradle foojay toolchain-resolver plugin removed (since
# v2.7.4) — the offline build scanner rejects it as it can fetch a JDK; (3) AGP's
# dependency-metadata block no longer embedded (`dependenciesInfo { includeInApk
# = false }`) — the binary scanner rejects it as an "extra signing block". All
# three live outside the zip entries or are inert, so v2.7.5 is functionally
# identical to v2.7.3 and reproduces byte-for-byte from source. v2.7.4 and
# earlier trip one of these checks — do not target them. Subsequent versions are
# added automatically (AutoUpdateMode).
Builds:
- versionName: 2.7.2
versionCode: 20702
commit: v2.7.2
- versionName: 2.7.5
versionCode: 20705
commit: v2.7.5
subdir: app
gradle:
- yes
@@ -49,10 +53,13 @@ Builds:
AllowedAPKSigningKeys: 5cdaee8eb31cb0df9157c646ae3ec8b3dc43b5bb72624e088c9ddea0c4eb5ec1
# Our own developer-signed APK for reproducible-build verification. %v -> the
# versionName, so v2.7.2 resolves to calendula_v2.7.2.apk on the self-hosted repo.
# versionName, so v2.7.5 resolves to calendula_v2.7.5.apk on the self-hosted repo.
Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/calendula_v%v.apk
AutoUpdateMode: Version v%v
UpdateCheckMode: Tags
CurrentVersion: 2.7.2
CurrentVersionCode: 20702
# After this one-time submission F-Droid auto-tracks new vX.Y.Z tags and creates
# build entries itself — no manual recipe edits per release. The tag regex keeps
# it to release tags only.
AutoUpdateMode: Version
UpdateCheckMode: Tags ^v[0-9.]+$
CurrentVersion: 2.7.5
CurrentVersionCode: 20705

View File

@@ -0,0 +1,6 @@
### Changed
- Build cleanup that lets Calendula ship in the official F-Droid repository:
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
reproducible build process disallows. No functional or visible changes — this
is the same app as 2.7.3.

View File

@@ -0,0 +1,6 @@
### Changed
- Further build cleanup for the official F-Droid repository: stopped embedding
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
scanner rejects as an extra signing block. No functional or visible changes —
the same app as 2.7.4, just without that Play-oriented metadata blob.

View File

@@ -0,0 +1,38 @@
### Added
- Find events fast. A search button in the top bar of every calendar view opens
a search box — type a couple of letters and matching events (by title,
location or description) appear, soonest first with past events below. Tap a
result to open it. Search covers your whole calendar, not just what's on
screen, and skips calendars you've hidden. A recurring event shows its next
occurrence rather than the date the series first started.
- A current-time line in the day and week views. A thin coloured line marks the
present moment across today's column, so you can see at a glance where you are
in the day. It updates every minute and only appears when today is in view.
- Add and remove event guests. The create/edit form now has a Guests section:
add people by email or pick them from your contacts, mark each as required or
optional, and remove them. Calendula never sends invitations itself (it has no
internet access) — it only records the guests; if the event lives on a synced
account, that account may email them when it syncs, and on a local calendar no
one is notified. The form tells you which applies. Picking a guest from
contacts needs no contacts permission.
- Pick a location from your contacts. A contacts button beside the location
field drops a contact's address straight into an event — handy for a meeting
at someone's home or office. Like the guest picker, it needs no contacts
permission.
- A "New event" Quick Settings tile. Add it to your quick settings to jump
straight into the new-event form from anywhere. Settings → New event has a
one-tap button to add the tile (Android 13+); on older versions you can add it
from the quick-settings editor.
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
reminder; snooze hides it and brings it back after a delay you pick in
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
system won't re-post a reminder on its own, so Calendula schedules an exact
alarm to bring a snoozed one back on time.
### Changed
- Event details now show each guest's email beneath their name, instead of only
when no name is available.
- Crash and problem reports now open on the project's public Codeberg tracker,
where anyone can register and file an issue. Nothing is sent automatically —
you still review the report and submit it yourself in the browser.

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.calendula).
#
# F-Droid only republishes OUR signed binary if a from-source build reproduces it
# byte-for-byte and the binary carries no extra signing blocks. If any invariant
# below regresses, the official repo silently stalls on the last good version
# (fails safe — but you'd be stuck on an old release without noticing). So fail
# loudly here, on every PR.
#
# Each invariant was learned from a real fdroiddata CI rejection (see
# docs/fdroid-official/ and the v2.7.3 -> v2.7.5 history):
# 1. vcsInfo { include = false } — else AGP embeds env-dependent git
# metadata (META-INF/version-control-info.textproto) -> not reproducible.
# 2. no foojay toolchain resolver — F-Droid's offline source scanner
# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over
# the network at build time).
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
# binary scanner rejects as an extra signing block.
set -euo pipefail
APP="app/build.gradle.kts"
SETTINGS="settings.gradle.kts"
fail=0
# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file
# as one record so the match can span newlines; [^}] keeps it inside the block.
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then
echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded."
else
echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2
echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2
fail=1
fi
# 2. The foojay toolchain resolver must not be present in any Gradle script.
gradle_files=("$SETTINGS" "$APP")
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
if grep -qi 'foojay' "${gradle_files[@]}"; then
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
echo " (it can fetch a JDK over the network). Remove the plugin." >&2
fail=1
else
echo "OK: no foojay toolchain resolver — offline build scanner stays happy."
fi
# 3. AGP dependency-metadata block must not be embedded in the APK.
if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then
echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block."
else
echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2
echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2
echo " Signing Block, which F-Droid's binary scanner rejects." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
echo "stall. Fix the above before merging. See docs/fdroid-official/." >&2
exit 1
fi
echo "All reproducible-release invariants hold."

View File

@@ -11,9 +11,6 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)