Compare commits

..

19 Commits

Author SHA1 Message Date
372bea8d5a Size the About logo to the card, not the card to the logo
At 72dp the launcher mark set the card's height on its own, towering over
the two text lines beside it and over the 40dp category chips on the rows
below. 56dp puts it at roughly the height of its own text, a step above the
row chips — still the block's anchor, no longer its outlier.

The mark is a vector, so this is purely a layout size; the 1.5x launcher-mask
overscan now lives in a constant rather than a second hardcoded number, so it
holds at whatever size the chip takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:16:41 +02:00
85ebbc9952 Show what a setting does, not just what it's called
The settings hub sorted the options into places you'd look for them; this
gives the pickers themselves something to say. Three rungs, cheapest first:

- Per-option summaries where the label names something it doesn't spell out:
  each agenda range resolves to real dates (so "This week" and "Next 7 days"
  stop being the same words), all-day reminders name the hour they actually
  fire at, time formats render a sample, "follow the system" says which way
  it currently falls, visibility says what other people see, widget sizes
  give their text scale, recurrence presets list their next three dates.
- Live previews where the effect is visual and no words carry it: past
  events (show/dim/hide, drawn with the agenda's own rows and filter), week
  start (the real month grid in the user's own style), and a font specimen
  set in the role each picker governs. These apply on tap and stay open —
  closing would hide the thing the screen exists to show.
- Everything else left as plain rows.

Recurrence gets a proper expansion (domain/RecurrenceOccurrences.kt) rather
than a guess: monthly on the 31st skips short months, 29 February yearly
only lands in leap years, weekly blocks count from Monday (the WKST that
applies, since toRRule writes none), and COUNT counts real occurrences.
Years appear in the list as soon as it leaves its starting year. JVM-tested.

Alongside, in the hub itself: source, licence and privacy move out of the
About card into rows at the foot of the page; support becomes the closing
row of the card's own group instead of a button inside it; category chips
cycle their accents so no group is a single colour; and section headers now
share the cards' left margin (they sat 8dp to the right of everything else).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:12:59 +02:00
08fd3467ad Sort settings into the places you'd look for them (#69)
Appearance had become the drawer everything got tossed into: 18 rows, half
of which weren't appearance at all. Views held three. Backup hid inside the
calendar manager, where nobody goes looking for "keep a copy of my
calendars".

The hub now reads as three labelled groups — look & behaviour, data, app —
and everything sits under the heading it belongs to:

- Appearance keeps theme/colour, the two fonts, and the app name (18 → 6).
- Views takes the per-view settings, grouped by the view they change: all
  views, month, week & day, agenda — then the two ordering lists (3 → 17).
- Widgets & tiles is new: the agenda widget's own range and size, plus the
  Quick Settings tile. "Agenda range" no longer sits one row above "Agenda
  widget range" pretending to be the same setting.
- Backup & restore is new: export, restore and automatic backup, lifted out
  of the calendar manager, which keeps a pointer row to it. Same screen
  logic, same either/or fallback when there's nothing exportable; it shares
  CalendarsViewModel and is hoisted in CalendarHost like the manager, since
  both are driven by the calendar list rather than by preferences.

Descriptions follow one rule now: a toggle says what happens when it's on
(Dynamic colour finally does), and a picker row shows its value, with the
explanation in the picker itself when the value alone says nothing — the
shape widget size and month style already used. Four picker descriptions
added, no string keys renamed or removed, so the translations stay
consistent.

SettingsScreen.kt is split one file per sub-screen (2108 → 409 lines); the
2000-line single file was why this drifted in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:51:41 +02:00
Jean-Luc Makiola
02cf32b537 Draw widgets at a size you pick, not a measured one (#103, #51) (#104)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/104
2026-07-30 19:22:35 +02:00
Jean-Luc Makiola
e44d70e18b Start new events on the selected day in Split month view (#87) (#106)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/106
2026-07-30 18:58:55 +02:00
Jean-Luc Makiola
401e067945 Reminder delivery review fixes (#75) (#105)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/105
2026-07-30 17:25:50 +02:00
Jean-Luc Makiola
a0827202b3 Schedule and fire reminders in-house (#75, round two) (#102)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/102
2026-07-30 16:19:03 +02:00
Jean-Luc Makiola
36bbb3ff2e Four bug fixes for 2.17.0 (#89, #81, #79, #77) (#101)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/101
2026-07-30 16:18:46 +02:00
Jean-Luc Makiola
1d07b64a28 fix(search): date all-day results in UTC, like every other view (#82) (#88)
Fixes [#82](https://codeberg.org/jlmakiola/calendula/issues/82) — found while investigating [#67](https://codeberg.org/jlmakiola/calendula/issues/67).

Search formatted a result's date with `ZoneId.systemDefault()`, while the month,
week, day, agenda and detail surfaces all resolve an all-day event's dates in
UTC. All-day events are stored at UTC midnight with an exclusive end, so west of
UTC that difference is a whole day: an event on the 19th came back from search
dated the 18th, contradicting the grid it was filed in. East of UTC the offset
lands on the same date, which is why it went unnoticed.

### What changed

The rule was already written down three times — a private helper in
`AgendaUiState`, inline in `coversDay`, inline in `formatWhen` — so rather than
add a fourth copy, `dateZone` / `spanFirstDay` / `spanLastDay` /
`spansMultipleDays` move into `domain/Models.kt`, where they are pure date logic
rather than agenda UI state. Search reads its date through `spanFirstDay`; the
clock time stays in the device zone, as it is only ever rendered for timed events.

### Testing

New `domain/EventInstanceSpanTest` pins both directions: the west-of-UTC case
from this issue and the east-of-UTC leak from #65, plus multi-day spans, timed
events following the device zone, and zero-length events.

Local sweep green: `test` (541), `lint`, `assembleDebug`, `check_translations.py`.

On-device reviewed on the Pixel 10 with the device timezone set to New York
(fix confirmed) and back to Berlin (no regression in search, month, week, day,
agenda or the agenda widget).

---
_Recreated on Codeberg from Gitea PR #102 (same head `7c94425`, unchanged) as part of the forge migration. Already on-device signed off._

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/88
2026-07-29 21:47:08 +02:00
d037492cf6 Merge remote-tracking branch 'origin/main' into release/v2.17.0
# Conflicts:
#	CHANGELOG.md
2026-07-29 21:38:42 +02:00
8e2109d073 feat(calendars): explain a calendar's state, and why one is missing from the picker (#76, #78) (!101)
Follow-up to #97, on the same release line. After the visibility fix a calendar can be absent from the pickers for three different reasons the app knows and never said; this closes that gap.

**Settings → Calendars names the state** (#76, #78): read-only calendars are marked `Read-only`; ones whose account isn't syncing events to this device are marked `Not synced`, sorted to the bottom of their account, dimmed, and left **without a switch** — visibility can't reveal events that aren't on the device, so the control did nothing.

**Both pickers end in a "Missing a calendar?" row** (#76) opening the calendar manager, where those labels then say which reason applies. The manager overlay moved to the end of the host's overlay stack so it covers every surface that can open it (Settings, both event forms, the import picker).

Review notes:

- **Supporting text, not chips.** A row can carry several states at once next to a live switch; M3 supporting text composes there, static badges don't (and a non-interactive chip reads as a broken button).
- **`sync_events = 0` counts as "not synced" for account-backed calendars only.** Nothing syncs a device-local calendar by definition, and another app's local calendar can hold real events at 0 — the unsoundness that made the first #75 migration guard wrong. Covered by a test.
- **Excluded calendars stay out of the pickers** rather than being listed unpickable — a handful of read-only subscriptions would crowd out the ones you can actually choose.

541 JVM tests green (6 new), lint clean, check_translations clean. **On-device review owed**; the three new string keys need the Weblate backfill.

Reviewed-on: #101
2026-07-26 17:17:02 +00:00
bf6415c023 Merge pull request 'fix(calendars): one visibility model, so reminders can't silently go missing (#75)' (!97) from fix/calendar-visibility-model into release/v2.17.0
Reviewed-on: #97
2026-07-25 20:03:14 +00:00
7aef01d95e docs(architecture): record what the second review pass changed
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m57s
The visibility section claimed the reminder side needed no per-calendar
handling. It does on the one path where VISIBLE was never written: an alert the
notifier silences keeps its SCHEDULED state while its event is still ahead, and
switching the calendar back on re-posts it, so the provider's own table is the
stash the deleted SuppressedReminderStore used to be.

Also rewraps the paragraph and separates it from the one that follows, which it
had been running into since the section was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:38:34 +02:00
ce4d6bc4d1 fix(calendars): keep an event's own calendar when it is switched off
Excluding switched-off calendars from the event form's picker is right for
*targets*, but it also dropped the calendar an event already lives in. Editing
an event of a writable calendar switched off on this device (from a widget, a
deep link, another app's ACTION_EDIT) rendered the calendar row as the red "no
calendar" error, with the picker still enabled — so any pick turned the save
into a calendar move nobody asked for. The event's own calendar is added back
whenever it isn't among the targets, the way the managed special-dates case
already did; a calendar the app may not write to is still no target.

Settings → Notifications had missed the same predicate swap: it kept offering
per-calendar reminder overrides for switched-off calendars, where the provider
schedules no alarms and the setting could never fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
4ad805e747 fix(calendars): don't flash a flushed calendar's events back on
The reconciler writes VISIBLE=0 and then releases the id from the pending set,
but the ContentObserver that invalidates the repository's cached calendar
snapshot is dispatched through the main looper and arrives later. Until it did,
the released set was read against the snapshot from before the write: the
calendar reported as on again and instances re-admitted exactly the events the
migration was hiding — on a cold start, for as long as the busy main thread took.

The snapshot cache is keyed on the pending set as well as the tick now, so any
read that sees a changed set re-queries the provider; a change to the set also
re-runs the flows, and the pending ids are read in the same pass as the
calendars rather than combined in from a live flow. Both id sets are deduped at
the prefs seam (the store is shared with SettingsPrefs, so every unrelated write
re-emitted them) and calendars() collapses identical lists, which keeps those
re-queries as rare as they should be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
bb6e3ad336 fix(calendars): only tell upgrades about the visibility change, and reconcile on every grant
Two holes in the reconciler, both found by review.

The one-time notice armed on any device holding a calendar at VISIBLE=0, which
is the norm on a fresh install: a second account's calendars, "Holidays in X", a
subscribed calendar. A brand-new user got a changelog dialog about a migration
they never experienced, right after onboarding. It is gated on
firstInstallTime != lastUpdateTime now, and a fresh install retires the notice
unshown ahead of the permission check — so an update installed before the first
grant can't make it look like an upgrade afterwards.

The catch-up run hung off PermissionViewModel.onGranted, which only fires for the
in-app request. Granting from Android's app-settings screen comes back through
RootScreen's ON_RESUME, so an upgrading user who took that route kept their
inherited switch-offs unflushed — their events filtered app-side while the
provider went on scheduling the reminders they asked to stop. The trigger sits on
RootScreen showing the app instead, which covers both routes. To keep that cheap,
a settled run now returns after two DataStore reads instead of querying every
calendar first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
edbeadfa30 fix(reminders): stop marking a silenced reminder handled and losing it
"With VISIBLE=0 the provider creates no alert rows" justified deleting the
suppression stash, but it only holds where the flag was actually written. Where
the switch lives in pendingDisabledCalendarIds — a read-only install, or an
upgrade whose flush hasn't landed — the provider still holds VISIBLE=1 and keeps
creating and broadcasting rows. The receiver silenced those in
ReminderNotifier.post and then marked the whole due batch STATE_FIRED, and
dueAlerts only ever returns STATE_SCHEDULED, so switching the calendar back on
before the event could no longer surface the reminder: it was gone.

post() now reports whether it put a notification up, and the receiver marks what
it posted plus what it silenced for an event already over (handledAlertIds). A
silenced alert for an event still ahead stays scheduled, which makes the
provider's own table the stash SuppressedReminderStore used to be — no local
mirror, no serialization. ReminderRecovery re-posts those rows when the calendar
is switched back on, so recovery doesn't wait for the next unrelated broadcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:33:58 +02:00
ef48717e2c fix(calendars): hide-only visibility reconcile, and keep it working read-only
Code review of the one-visibility-model fix (#75) found the reconciliation
reaching further than it should and the read-only case falling through it.

The migration switched calendars *on* to keep the upgrade invisible, but
"not disabled in Calendula" is the default for every calendar, including ones
the user deliberately hid in Google Calendar, Etar or DAVx5 — those would
reappear there and start firing reminders from a switch the user never touched.
Its sync_events guard didn't hold either: an ACCOUNT_TYPE_LOCAL calendar another
app created can sit at sync_events=0 while holding real device-local events. The
reconcile now only hides, and a one-time notice explains that visibility follows
the device and where to change it, instead of quietly rewriting other apps'
state.

Only READ_CALENDAR gates the app, so a read-only install could not write the
flag at all: every calendar it had switched off came back with its events and
its reminders, and the switch couldn't undo it. Those switch-offs are kept
app-side now (the retired disabled-set key, re-read under a new name), folded
into the visibility every consumer reads, and drained into the provider entry by
entry once WRITE_CALENDAR arrives — which also makes a part-applied run resumable
without re-applying a switch the user has since flipped by hand.

Also: restore the ReminderNotifier.post gate, the one path a snooze re-shown
from our own alarm passes; move the whole reconcile inside its try/catch, so a
damaged preferences file can't crash the process at launch; share one Calendars
query per provider tick across the flows that need it; and give the reworded
Settings hint new keys, so five locales stop rendering the retired app-only
wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:16:55 +02:00
41593a0e9d fix(calendars): make the Settings toggle the one visibility model (#75)
Reminders never fired for a calendar hidden at system level and nothing hinted
at it: the provider only schedules reminder alarms for Calendars.VISIBLE=1,
while Calendula filtered with its own disabledCalendarIds pref and parsed
isVisibleInSystem without ever using it — two models that could disagree
indefinitely.

Settings → Calendars now writes Calendars.VISIBLE, one calendar per update
(CalendarProvider2 skips its own checkNextAlarm() reschedule for any selection
that isn't _id=), and every display predicate reads isVisibleInSystem. The
drawer's filter sheet stays a purely in-app declutter and still leaves reminders
alone.

With VISIBLE=0 the provider creates no alert rows, so there is nothing left to
suppress: the disabled-calendar gates, SuppressedReminderStore and the re-enable
recovery are gone. A one-shot migration reconciles the retired set with the app's
state winning — enabled in-app and syncing gets shown, disabled gets hidden,
everything else untouched — so the upgrade changes nothing the user sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:44:56 +02:00
87 changed files with 1993 additions and 1546 deletions

View File

@@ -1,30 +0,0 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
subscribed/WebCal, birthdays — provider behaviour differs
a lot per account type, so this often points straight at
the cause -->
- Time zone: <!-- only if the problem involves dates or all-day events -->

View File

@@ -1,18 +0,0 @@
# Kept enabled so anything that doesn't fit the four templates still has a way
# in (the `ToDo` label exists for exactly those).
blank_issues_enabled: true
contact_links:
- name: Translate Calendula
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
about: >-
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
about: >-
Before opening a pull request: the issue-first workflow, which release
branch to target, how to build (there's a submodule), and the
architectural rules a change is reviewed against.

View File

@@ -1,41 +0,0 @@
<!--
Thanks for contributing to Calendula!
Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• Features need a discussed issue first — an undiscussed feature PR may be
closed unmerged even when the code is good.
• Target the release branch for your issue's milestone (milestone 2.18.0 →
release/v2.18.0), not main. If you targeted main, just say so below and it
will be retargeted.
-->
### What this changes
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes.
./gradlew lint test assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
- [ ] `./gradlew lint test assembleDebug` passes locally
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
- [ ] No planning or design documents committed

View File

@@ -37,29 +37,14 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
# 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`.
- name: Classify change scope
id: scope
env:
# Deliberately a skip-list, not a build-list: a path nobody thought
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.planning/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^renovate\.json5$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
# Normally the bare branch name; tolerate a full ref, which would
# otherwise make the merge-base lookup fail and quietly degrade this
# guard into "always build".
BASE="${BASE#refs/heads/}"
if [ -z "$BASE" ]; then
echo "No base branch on this event — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
@@ -73,15 +58,11 @@ jobs:
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
if [ -n "$RELEVANT" ]; then
# Naming them makes "why did my docs PR build for four minutes?"
# answerable from the log alone.
echo "Build-relevant changes:"; echo "$RELEVANT"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "Docs/metadata-only change — skipping the Android build."
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
fi
- name: Setup Java

View File

@@ -0,0 +1,23 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -5,7 +5,7 @@ title: "Crash: "
labels:
- bug
- crash
- priority/high
- priority:high
---
<!--

View File

@@ -1,4 +1,4 @@
name: Release — F-Droid repo + Gitea/Codeberg release + Play
name: Release — F-Droid repo + Gitea/Codeberg release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
@@ -9,12 +9,6 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play is the only channel that can reject a good build for
# reasons the pipeline can't see, and that must not endanger a release which has
# already shipped to F-Droid and Codeberg. It skips cleanly until the
# PLAY_SERVICE_ACCOUNT_JSON secret exists.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
@@ -483,168 +477,3 @@ jobs:
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the
# APK. The release key signs it, but Play only ever treats that key as the
# *upload* key: Play App Signing re-signs with Google's own key before
# delivery. A Play install and an F-Droid install therefore carry
# different signatures and cannot update each other. That divergence is a
# deliberate, documented choice (docs/RELEASING.md), not an accident.
#
# Built LAST and `continue-on-error`, both deliberately: everything above
# has already shipped by this point, and nothing Play-related may put that
# at risk. Sitting mid-job without continue-on-error, this block took the
# whole 2.17.0 release down with it — no F-Droid publish, no tag, no
# Codeberg mirror — over an artifact upload. A failure here now costs the
# Play upload and nothing else.
#
# Nothing here touches the F-Droid path: the AAB is never copied into the
# repo, never attached to a release, and its build cannot change the APK
# published above.
#
# AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets
# deobfuscated stacktraces without a separate mapping upload.
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleRelease
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
# any other forge as an unsupported GHES instance and fails before it ever
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
# artifact API fine; only the client-side check is wrong. This fork is that
# client with the check removed. Pinned to a commit, not the v4 branch: a
# third-party action in the signing pipeline must not change under us.
- name: Hand the AAB to the Play job
if: env.IS_RELEASE == 'true'
continue-on-error: true
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: app/build/outputs/bundle/release/app-release.aab
if-no-files-found: error
retention-days: 14
# Google Play channel.
#
# A separate job, on purpose, running only AFTER the F-Droid publish and both
# forge releases have completed. Play is the one channel that can reject a
# perfectly good build for reasons outside the pipeline (listing rules, policy
# review, API outage, a track that needs manual promotion). Isolating it means
# such a rejection surfaces as one red job next to a release that already
# shipped everywhere else, instead of failing the workflow that publishes it.
#
# Not a `container:` job even though a fastlane image exists: act_runner does
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# Where the bundle lands. `internal` by default so a release reaches
# testers rather than the public, and promotion to production stays a
# deliberate human action in the Play Console — the same posture as
# holding UI releases for on-device review. Override with the PLAY_TRACK
# repo variable once the flow is trusted.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
# it instead of committing — used to rehearse the first upload.
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
BUNDLE_PATH: vendor/bundle
steps:
- name: Checkout
uses: actions/checkout@v4
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
# of the release pipeline keeps working during setup — same contract as
# the Codeberg mirror step.
- name: Write the Play service-account key
id: key
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
echo "configured=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
# Fail here, with a clear message, rather than inside fastlane: a
# mangled multi-line secret is the likeliest setup mistake.
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
echo "configured=true" >> "$GITHUB_OUTPUT"
# Same GHES-detection problem as the upload side, same fix — see the
# handoff step in the release job.
- name: Download the AAB
if: steps.key.outputs.configured == 'true'
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: dist
- name: Install Ruby
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
# ruby-dev + build-essential: several of fastlane's dependencies build
# native extensions.
$SUDO apt-get install -y ruby-full ruby-dev build-essential
ruby -v
# Only the first release pays the full gem build; afterwards this restores.
- name: Cache bundled gems
if: steps.key.outputs.configured == 'true'
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Install fastlane
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
gem install bundler --no-document
bundle config set --local path vendor/bundle
bundle install --jobs 4
bundle exec fastlane --version
- name: Upload to Play
if: steps.key.outputs.configured == 'true'
env:
SUPPLY_JSON_KEY: play-service-account.json
# supply is chatty on a TTY-less runner otherwise.
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
set -euo pipefail
test -f "dist/app-release.aab"
bundle exec fastlane deploy \
aab:"dist/app-release.aab" \
track:"$PLAY_TRACK" \
release_status:"$PLAY_RELEASE_STATUS" \
dry_run:"$PLAY_DRY_RUN"
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
# The workspace is reused between runs on a self-hosted runner, so the
# credential must not outlive the job.
- name: Shred the service-account key
if: always()
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json

16
.gitignore vendored
View File

@@ -50,27 +50,11 @@ google-services.json
.DS_Store
Thumbs.db
# Editor swap/backup files
*.swp
*.swo
*~
# F-Droid local artifacts (the pipeline generates them in CI)
/fdroid/
# KSP
.ksp/
# Google Play Developer API service-account key. Reconstructed in CI from the
# PLAY_SERVICE_ACCOUNT_JSON secret and shredded afterwards — never committed.
/play-service-account.json
# fastlane (Play uploader only — see fastlane/Fastfile)
/fastlane/report.xml
/fastlane/README.md
/vendor/bundle/
/.bundle/
Gemfile.lock
# Claude Code
/CLAUDE.md

View File

@@ -17,10 +17,9 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
## Current Milestone
Milestones 1 (read, v1.0) and 2 (write support, v1.1v2.0.0 incl. reminder
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Everything since is
tracked as issues and milestones on Codeberg:
<https://codeberg.org/jlmakiola/calendula/milestones>. A milestone maps to its
`release/vX.Y.Z` branch.
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0
(power-user features) plus an undecided "Locations & People" idea backlog;
see `ROADMAP.md`.
## Stack
@@ -28,26 +27,10 @@ Kotlin 2.3.21 (paired with KSP 2.3.9 — Kotlin 2.4.0 has no KSP release
yet, do not upgrade until one ships). Jetpack Compose + Material 3
Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
with Version Catalog. AGP 9.2.1, Gradle 9.5.1. JVM target 17 (exactly — AGP
requires it).
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired
in as a Gradle composite build, so it compiles from source rather than resolving
as a dependency.
## Constraints
- **Platform:** Android-only, Android 10+ (minSdk 29), targetSdk 36. No iOS.
- **Offline-first:** all data lives in `CalendarContract` — no app database, no
sync stack. No `INTERNET` permission; any feature needing one is an explicit
product decision first.
- **Privacy:** zero telemetry, zero analytics.
- **i18n:** German + English from day one; further languages come from community
translators via the self-hosted Weblate, which owns every `values-*` file.
- **Tests + CI from day one**, JVM-first.
- **Reproducible release builds**, so the official F-Droid repo can verify the
published binary against a from-source rebuild.
- **Licence:** MIT.
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
any feature that would need one is an explicit product decision first.
## Naming
@@ -57,9 +40,5 @@ shows a stylized "1" on a slate squircle.
## Source
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
releases, plus contributor CI. The self-hosted Gitea instance is build
infrastructure only: it holds the signing key, runs the release pipeline, and
publishes the self-hosted F-Droid repo on Hetzner. Codeberg push-mirrors `main`
and tags to Gitea, where a bumped `versionName` triggers the release. Also
published to the official F-Droid repo. See `docs/RELEASING.md`.
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
Hetzner. Same infrastructure as `HouseHoldKeaper`.

53
.planning/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,53 @@
# Calendula — Requirements
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
- [x] Data Layer over `CalendarContract`
- [x] Permission flow (`READ_CALENDAR`)
- [x] Month view (S1)
- [x] Week view (S2)
- [x] Day view (S3)
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
- [x] Multi-Calendar Filter (M3)
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
- [x] View-Switcher (M1)
- [x] Settings screen (M4)
- [x] Empty / no-permission / no-calendars states
- [x] German + English localization
- [x] Loading/Failure/Success states per screen (architectural pattern)
## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
- [x] Reminder notifications (v1.4) — **reversal of the original
"system handles reminders" assumption:** Calendula targets
sole-calendar-app users, so it posts reminder notifications itself
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
- [x] Conflict dialog on save + store polish (v2.0)
- Quick-add — **cut from scope** (the prefilled form covers it)
- Calendar switching while editing — moved to v3 backlog
### Out of Scope (V3+)
- Home-screen widget
- Full-text search
- Tablet/foldable-specific layouts
- Locations & People ideas (contact picker, OSM autocomplete) — see
`ROADMAP.md` idea backlog, undecided
- iOS support (Android-only by design)
## Constraints
- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore
- **Tech stack pin:** Hilt 2.59.2 + KSP 2.3.9; Kotlin 2.3.21 (KSP for Kotlin 2.4.0 not released yet). Material 3 pinned to `1.5.0-alpha21` (Expressive APIs only exist in alpha). Re-evaluate when KSP/Material3 stable land.
- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target
- **Offline-first:** all data lives in `CalendarContract`; no app-side network
- **Privacy:** zero telemetry, no analytics
- **i18n:** German + English from day one
- **Tests + CI from day one**
- **License:** MIT

587
.planning/ROADMAP.md Normal file
View File

@@ -0,0 +1,587 @@
# Calendula — Roadmap
## v0.x — Pre-Release
| Version | Milestone | Status |
|---|---|---|
| v0.1 | Foundation & CI | complete |
| v0.2 | Data Layer & Permission Flow | complete |
| v0.3 | Month + Week + Day views, view switcher | complete |
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
| v0.6 | Full event read — surface every readable field | complete |
| v1.0 | First public release — polish pass, F-Droid | complete |
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
## v0.6 — Full event read
Round out the read-only model so a detail view shows everything the system
actually stores, before write support starts. Scope = `CalendarContract`
columns we don't yet read/display:
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
- **Availability** (`TRANSP`) — Free / Busy chip
- **Attendee extras** — role (required / optional / organizer) + the user's own
`SELF_ATTENDEE_STATUS`
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
URLs are instead surfaced by linkifying the description text
- **Access level / class** (private / confidential) — small chip (optional, trivial)
All of the above shipped in v0.6.0 (2026-06-11).
Deliberately out of v0.6:
- Recurrence exception / modified-occurrence badges — `Instances` already
resolves correct per-occurrence times for display; this only matters for
editing, so it folds into v2
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
(provider limitation, not our choice)
## v1.0 — First Public Release — shipped 2026-06-11
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
after v0.6 (full event read) plus the onboarding-screen polish pass.
### Polish backlog (pre-1.0)
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support (complete, shipped 2026-06-11)
Delivered in four releasable slices (plan:
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
guide here, not a contract — scope per slice is decided as we go.
| Version | Milestone | Status |
|---|---|---|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
v2.0 scope was re-cut on 2026-06-11, after v1.4:
- **Occurrence edit** already shipped early, in v1.3.
- **Quick-add** is **cut from scope**: the full form already opens prefilled
(visible day, last-used calendar, optional fields hidden), so the sheet
would only save one screen transition while adding a second create-surface
to maintain. Revisit only if real-world feedback says creation feels heavy.
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
honest implementation is copy+delete like Google Calendar, with sync-identity
and attendee side effects).
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
the row as it was when the form loaded; on external change, ask
overwrite / discard. Closes the silent-clobber gap on synced calendars.
## v1.4 — Reminder Notifications
**Essential**, not nice-to-have: Calendula targets users for whom it is their
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
Calendar. The calendar provider schedules reminders and broadcasts
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
notification — a calendar app must. We become that app (the Etar model).
Scope:
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
no foreground service.
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
notification channel; tap opens event detail.
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
toggle, **default ON**, with copy warning that a second calendar app with
notifications on will cause duplicate reminders. Mirrored into Settings
(reversible).
Deliberately deferred (add only if needed):
- Snooze / dismiss notification actions (Etar has them)
- Battery-optimization exemption prompt for delivery reliability
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
- Month grid shows real events as continuous multi-day bars (not just dots)
- View section in the navigation drawer to switch Month / Week / Day
- Fix: text cursor no longer jumps in event text fields
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
- Tap an empty slot in day/week → create form prefilled with that day + the
tapped hour (snapped to the hour, 1 h long)
- Local (device-only) calendar management in a full-screen editor from
Settings → Calendars: create / rename / recolor / delete, with name,
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
- Synced calendars listed read-only, grouped by account, each with a
per-account "manage in source app" deep-link (resolved from the account's
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
editor share one input style)
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
gallery app) across Settings, the calendar manager and the navigation drawer.
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
whose title collapses on scroll) + `GroupedRow` (Position-based corner
grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub with About card on top and sliding sub-pages
(Appearance / New event form / Notifications); theme/week-start/language
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
chips; `ic_gitea.xml` for the About "Source" button.
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
drawer scrolls as one with the active view highlighted.
- Cards use `surfaceContainerHigh` for readable contrast.
- Donate button on the About card deferred (target TBD).
---
# Backlog (theme-based, post-v2.1)
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
consolidated here by theme. Within a group, **(in progress)** /
**(next)** mark what is being or about to be worked; everything else is an
approved-but-unscheduled idea unless tagged **(idea)** /
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
## Near-term sequence (ranked, 2026-06-16)
The theme groups below are the full menu; this is the committed *order* for
the next stretch. Ranking favours finishing the current create/edit + calendar
arc before opening new fronts, then cheap-relative-to-value items and ones that
unblock a later item. Order is a plan, not a contract — revisit after each lands.
**Tier 1 — finish the current arc (create/edit + calendars)**
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
above)*
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
### Settings redesign & restructure *(shipped v2.3.0)*
The original scope below is kept as a record; the implementation expanded from a
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
The settings screen has grown into a flat vertical scroll of divider-separated
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
will keep accreting rows (per-event-color defaults, default reminder, more
calendar entries are all queued). It needs structure before it gets unwieldy.
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
becomes a category list; each category opens its own destination. More
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
existing Calendars row, which already navigates out to its own screen.
Structure — top-level settings list → category destinations:
- **Appearance** → theme, dynamic colour, week start
- **Event form** → the 6 default-field toggles + the hint text
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
peer category row, no change to that screen
- **Language** → single control; keep as a top-level row that opens an
OptionCard directly (a whole sub-screen for one choice is overkill)
- **About** → kept inline on the top-level list as a card (read-only info,
not worth a navigation hop). Card layout, top → bottom:
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
as a subtitle beneath the name
- **Action buttons** (small, button-styled, sit in a row):
- **Source** — Gitea logo, opens the repo (`about_source_url`)
- **License** — opens the LICENSE file on Gitea
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
- **Version** — small version number at the bottom of the card
Scope:
- **Navigation** — add the settings sub-screen destinations alongside the
existing settings/calendars routes in `CalendarHost`; back pops to the
settings list (mind the existing `BackHandler` that guards against falling
through to the activity).
- **Fix the dialog-pattern violation** — theme, week-start and language use
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
(radio/dropdown/text-list dialogs are banned, see
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
- **Visual pass** — top-level category rows with leading icons; consistent
spacing and row affordances aligned with the event-form card design system.
Out of scope (no new settings *features* here) — this is a structure + style
pass on the existing controls; new toggles ride in with their own features.
**Tier 2 — navigation & daily-driver completeness**
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
**Tier 3 — platform reach (depends on Tier 2)**
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
default reminder **+ per-calendar override**, bundled with battery-exemption
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
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 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** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
the event model: attendees were already *read* (queried, mapped, shown on the
detail screen since v0.6) and are now *writable* from the form. An attendees
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
no-permission contact picker, edits/removes rows, and sets role (required /
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
caveat was **settled record-only** (Calendula never sends invites; the backend
decides delivery) — full sketch + decision in "Attendee editing" under Locations
& People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
- Move event to another calendar — sync-adapter minefield (copy+delete model)
**Bottom — deprioritised, not important**
- Duplicate event (detail action → prefilled create form) — moved here
2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
Pulled in opportunistically, not sequenced.
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
full-text search, the "New event" Quick Settings tile, and the now-line all
shipped there. **No tier work is currently committed as next;** the remaining
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
accessibility pass) plus the gated go/no-go items.
## Navigation & views
- ~~Tap an empty slot in day/week → create form prefilled with that
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
not added — single tap covers it)
- Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1)
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
the view and shrinks the day cells; the badge stays week-view-only.
- Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
history, finding an event is core completeness, not optional.
## Event editing & creation
- Duplicate event (detail action → prefilled create form)
- ~~**Per-event color**~~ *(shipped v2.4.0)*`EVENT_COLOR` / `EVENT_COLOR_KEY`
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
picker in the form, falling back to the calendar color when unset. Reused the
color-picker component and palette plumbing from local calendar management and
finished the create/edit theme.
## Calendars & accounts
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0**
name + color + description; rename / recolor / delete the calendars the app
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
synced calendars, open the app the calendar actually came from based on
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
…); fall back to system account/sync settings. Plus an "add account"
entry into system Accounts. Honest boundary for remote calendars.
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
client: **INTERNET permission, credential storage, the full server
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
intent to delegate the create to it. Cosmetic local edits (color/name)
to an existing synced row are possible but don't propagate to the server
and may be overwritten on next sync — not promised. Same explicit
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)*~~ **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.
### Disable a calendar in-app *(captured 2026-06-25)*
A second, heavier visibility level **above** the existing per-view filter. Today
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
a calendar's *events* from the month/week/day/agenda views — the calendar itself
still clutters the drawer filter list, the event-form calendar picker, and the
import target picker. "Disable" removes a calendar from the app's surfaces
entirely; "hide" stays the lightweight, frequently-toggled control.
**Two-level model (both kept):**
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
drawer. Toggles events on/off in the views; the calendar stays listed
everywhere. Operates only over the *enabled* calendars.
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
filter list, not in the event-form picker, not in the import picker, and its
events never appear (it's gated out of `instances()` like a hidden one). It
remains visible **only** in Settings → Calendars, where the enable/disable
toggle lives, so it can be brought back.
**Storage — app-side (DataStore), mirrors the hidden set.** Add
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
a Calendula-local preference, so other calendar apps are unaffected and the sync
adapters stay out of it (privacy-clean, reversible).
**Where the disabled set is applied:**
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
`calendarId ∈ (hidden disabled)` so a disabled calendar's events never show
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
the screens that need everything still get everything.)
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
(you can't hide/show what's disabled).
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
into a calendar you've removed from the app. Handle the last-used-calendar
preselect falling on a now-disabled calendar (fall back to first enabled
writable).
- `ImportViewModel` — exclude disabled from the import target list.
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
row on the existing `CalendarsScreen` (both the local and the synced/read-only
groups — disabling is an app-side view choice, independent of write access).
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
**Decided behaviour:**
- All calendars disabled → views show the existing empty state.
- A disabled calendar that still holds events: events simply vanish from
views/search until re-enabled (no data touched — it's a filter, not a delete).
- Deep links / notifications pointing at an event in a disabled calendar still
open its detail screen — detail is a direct id lookup, not an `instances()`
query, so disabling never strands an existing link.
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
Two themes bundled because both are "make reminders trustworthy" — the core of
the "Calendula is your only calendar app" promise.
**Built in this slice (A + the safe half of B):** global timed default reminder
+ a **separate all-day default** (day-scale lead times) + per-calendar override
(timed events), applied on create with manual-edit / calendar-switch / all-day-
toggle handling; three pickers + per-calendar override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link, no
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
Resolution model: all-day events use the all-day global default outright;
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
applied: form-reset state race, label-fn consolidation with the detail screen,
inline wrapper + single combined flow read.
**Deliberately deferred (documented decisions, not oversights):**
- *Absolute time-of-day for all-day reminders* — the all-day default is still
minutes-before-midnight (day-scale presets), not "9am the day before" (open
decision #2's richer half). Per-calendar all-day overrides also deferred.
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
(open decision #1). The battery exemption is the reliability lever; no
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
exemption lives only in Settings for now (onboarding flow untouched to keep
the change reviewable).
### A. Default reminders (global + per-calendar override)
**No provider backing.** `CalendarContract` has no column that auto-applies a
default reminder per calendar — Google's per-calendar defaults live server-side.
So both the global default *and* the per-calendar override are **app-side
preferences**, applied by us at event-insert time. We inherit nothing from the
synced calendar.
- **Storage (DataStore):**
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
reminders are expressed as minutes before midnight / day-before-at-time, not
minutes before a start instant — they need their own value).
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
**absent key = inherit global**, explicit `null` = "no reminder for this
calendar". (Same for an all-day override map if we want per-calendar all-day.)
- **Apply on create:** a fresh event prefills its reminders list from
override-or-global for the preselected calendar. Changing the calendar in the
form re-applies the *new* calendar's default **only if the user hasn't manually
edited the reminders** — track a dirty flag, mirroring the per-event-color
reset pattern (v2.4).
- **Edit semantics:** defaults apply to **new events only**; never rewrite
reminders on existing events on open or on calendar-switch-during-edit.
- **Settings UI (Notifications sub-page):**
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
/ 1 h / 1 day / custom), plus the separate all-day default.
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
or a Notifications subsection), each opening the same OptionCard with a
leading **"Use global default"** option.
### B. Delivery reliability (exact alarms + battery)
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
it directly undermines the feature's premise, so it rides in here.
- **Exact alarm — decision first:** trust the provider broadcast, or
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
fallback for API 3132 (user-revocable → settings deep-link prompt).
- **Battery-optimization exemption:** a *soft, optional* prompt via
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
auto-grant intent), honest copy: "Android may delay reminders to save battery;
exempt Calendula for on-time delivery." Shown once after the existing
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
are notorious for suppressing it).
### Decisions made (as shipped in v2.6.0)
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
battery-optimisation exemption is the reliability lever (simplicity + battery
cost won over the exact-alarm subsystem).
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
time-of-day ("9am the day before") deferred — see the deferred list above.
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
governing timed events only.
### Round two
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
## Sharing & interop
- ~~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
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## Quality & reliability
- **Accessibility pass** — TalkBack content descriptions across all screens,
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
F-Droid app; nothing tracks it yet.
- **Reminder delivery reliability** — exact alarms + battery-optimization
exemption; specced in the "Reminders — defaults & delivery reliability" slice
above (Tier 4 #9).
## Locations & People *(go/no-go, captured 2026-06-11)*
Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet:
- ~~**Contact address picker** for the location field via the system picker
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
the privacy story. The same no-permission mechanism was then reused for the
attendee email picker (v2.8.0).
- **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the
INTERNET permission** — first dent in the "no network access" promise;
if built: opt-in (off by default), honest copy, configurable endpoint
for self-hosters, onboarding footnote + F-Droid copy reworded. This
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** *(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
- Travel time / weather / smart suggestions (network, core-promise conflict)
- 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.

177
.planning/STATE.md Normal file
View File

@@ -0,0 +1,177 @@
# Calendula — Current State
*Last updated: 2026-06-22*
## Status
**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
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
- [x] Day view (S3) — single-column slice reusing the week layout
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
- [x] Full event read (v0.6) — reminders, status, availability, access level,
attendee role + self-response, foreign timezone, and linkified description
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
URL field was cut — no `CalendarContract` column backs it.)
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
detection (`CALENDAR_ACCESS_LEVEL``canModifyContents`, actions hidden for
WebCal/birthday calendars), delete from the detail screen (recurring:
"only this event" via cancelled exception / "all events in the series"),
repository + mapper tests
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
M3 date/time pickers with duration-preserving start moves, writable-only
calendar picker preselecting the last-used calendar, location, description),
"+" FAB on all three views prefilled with the visible day, `insertEvent`
with provider-correct all-day normalisation (UTC midnights, exclusive end),
domain/mapper/repository tests
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
upgrade), dirty-checked partial `update` on the Events row (recurring:
series DTSTART moves by the user's delta, DURATION instead of DTEND),
reminder diff by minutes (kept rows keep their method), simple recurrence
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
shown humanized), `EventFormField.Recurrence` incl. settings default,
recurrence also available on create; domain/mapper/repository tests.
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
on Mon+Fri"). Review rounds 24: occurrence edit pulled forward from v2.0
and made three-way like delete ("this" = exception row via
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
series update); delete equally three-way (truncation via RRULE UNTIL);
the edit-scope question moved to save time (Google model) — dirty
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
the "only this event" option
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
dedicated channel, tap opens detail (singleTop deep link); best-effort
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
with duplicate-reminders warning; Settings mirror. Provider only fires
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
refreshed, fastlane screenshots DE+EN captured on-device
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
continuous multi-day bars; navigation-drawer View section
(Month/Week/Day); cursor-jump fix in event text fields
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
(prefilled with that day + tapped hour, snapped to the hour); local
calendar management in a full-screen editor from Settings →
Calendars: create/rename/recolor/delete device-only calendars
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
read-only grouped by account with a per-account "manage in source app"
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub (About card on top → version mark at the foot) with
sliding sub-pages (Appearance / New event form / Notifications); token-
based icon chips; theme/week-start/language pickers migrated from
`DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons,
verbatim path) for the About "Source" button; en+de strings.
- Calendar manager: same collapsing scaffold + grouped rows; shared
`CalendarColorChip` (neutral chip, pastelised calendar glyph).
- Navigation drawer: branded header, grouped View switcher (active view
highlighted via `secondaryContainer`), the filter list restyled to
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
- Donate button on the About card deferred (target still TBD).
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
fallback; this adds the write side and the picker. Palette-backed calendars
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
calendars resets the choice (a key is account-scoped). A settings toggle
("Allow colors on unsupported calendars", off by default) extends the raw
path to synced calendars with no palette, with an honest "may not survive
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. 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

@@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.17.1] — 2026-07-30
### Added
- Settings → Calendars now says what is different about a calendar instead of
leaving you to guess. Ones you can only view — a subscribed calendar, a
@@ -30,39 +28,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The calendar picker in the event form and in the .ics import screen now ends
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]).
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
- The agenda widget's text size is yours to set. **Settings → Agenda → Agenda
widget size** offers Small, Medium, Large and Extra large, replacing the guess
the widget used to make from its own measurements. Small is what it looks like
today, so nothing changes until you turn it up ([#51]).
- A repeating event now shows you its next few dates, not just a description of
the rule. Both the preset list and the custom recurrence picker carry a
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
can mean something other than it sounds like, and only the dates say so. A
rule that can never fire says that instead ([#69]).
- The event visibility options now say who they affect. **Public**, **Private**
and **Confidential** each carry a line about what other people on a shared
calendar see — the part the four words on their own leave out ([#69]).
### Changed
- **Settings has been reorganised so each setting sits where you would look for
it.** One long undifferentiated list is now three labelled groups — Look &
behaviour, Data, and App — whose rows open sub-screens and say what they do
rather than only naming themselves. Appearance, Views, New event form,
Notifications and the new Widgets & tiles are separate screens now, so the
settings for a calendar view are no longer mixed in with the ones for the
app's colours or for the home-screen widgets ([#69]).
- Settings that are hard to picture from their name now show you what they do.
The week-start picker rearranges a real month grid as you choose, the
past-events setting previews a sample agenda day for Show, Dim and Hide, the
font pickers set a specimen line in the face you are choosing, and the Agenda
range options carry the dates each one actually covers. Options that follow
the system additionally name which way they currently fall ([#69]).
- **Backup & restore** is now its own Settings entry instead of living inside
the calendar manager, where it was easy to miss — keeping a copy of your
calendars is a different question from which calendars you have. The calendar
manager keeps a row pointing to it, and nothing about how backup or automatic
backup works has changed ([#69]).
- Calendula's source code now lives on **Codeberg**, where its issues already
were. The **Source code** and **License** links in Settings → About point
there, so reporting a bug and reading the code no longer land on two different
@@ -1252,4 +1223,3 @@ automatically, with zero telemetry and no internet permission.
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69

View File

@@ -1,184 +0,0 @@
# Contributing to Calendula
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
Android's `CalendarContract` — no app database, no sync stack, no network access.
That constraint shapes most review comments, so
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
code. This file is the practical how.
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
release docs is build infrastructure only; there is nothing to contribute there.
Be decent to the people you meet in the tracker.
## Start with an issue
| You want to | Do this |
|---|---|
| Add a feature | **Open an issue first** and wait for a go-ahead |
| Fix a bug | Open an issue, then a pull request |
| Fix a typo, a comment, or docs | Just open the pull request |
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
Features get an opinion before they get code: whether Calendula should do a
thing at all is the one decision a patch can't make. A feature PR that arrives
without a discussed issue may be closed unmerged even when the code is good —
please don't spend a weekend on one first.
Bugs are more straightforward, but still start with an issue: it's what carries
the milestone and gives the changelog something to link.
Issue templates cover bug, crash, feature and question. For a crash, let the app
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
captures the stack trace and prefills the form. The report contains app, Android
and device versions plus the trace; no calendar content, no personal data.
## Which branch to target
Calendula releases by merging a version bump into `main`, so `main` is a release
trigger rather than a staging area. Work is assembled on release branches first.
Once your issue has a milestone, that milestone names your branch:
| Milestone | Target branch |
|---|---|
| `2.18.0` | `release/v2.18.0` |
Every milestone has a matching branch. If it's somehow missing, target `main` and
mention it in the PR — it will be retargeted. Don't pick an older release branch:
they're kept after shipping, so the newest one isn't necessarily yours.
## Translations
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
Translations are owned by a self-hosted Weblate that writes to this repository
directly, and a hand-edit is overwritten on the next sync.
**[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and
fine — missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
```
before pushing. It reports those more clearly than lint's `MissingTranslation`
does.
## Build & test
```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
```
The `floret-kit` submodule is a composite build compiled from source. An
existing clone needs `git submodule update --init --recursive`, or nothing
resolves.
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
`JAVA_HOME` if your default differs.
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
go the `local.properties` route the included build needs its own copy at
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
easier path.
The Gradle wrapper is checked in, so no system Gradle is needed.
```sh
./gradlew lint test assembleDebug # roughly what CI runs
```
A single test class, or a pattern:
```sh
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
```
CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`,
`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid
metadata or the licence skip the Android build and go green quickly. More detail
in [`docs/BUILDING.md`](docs/BUILDING.md).
## The rules
These are the ones that turn into review comments.
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
feature rather than an oversight. Anything that would need one is a product
decision before it's a patch — the crash reporter deliberately opens a
prefilled web issue instead of posting anything itself.
2. **The provider is the only database.** No Room, no cache, no local mirror of
events. `CalendarContract` is the single source of truth, which is also why
externally synced changes work for free.
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
views recompose from fresh provider state. Hand-patching a list after saving
appears to work, then quietly diverges from what the provider actually stored.
4. **`domain/` has no Android imports.** Models, validation, recurrence
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
remain JVM-testable.
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
through `ColumnReader` instead of cursors. Instrumented tests are a last
resort, not a default.
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
DURATION, exception URIs, series splits), save-conflict detection and reminder
delivery (post-before-mark) follow provider-driven rules that are documented
in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from
the code alone.
7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP
metadata block are disabled on purpose so the official F-Droid repo can verify
our binary against a from-source rebuild.
`scripts/check_reproducible_release.sh` runs on every pull request, including
docs-only ones.
## UI conventions
Material 3 Expressive throughout, built from the system's own tokens and
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
settings rows.
**Selection pickers are full-screen.** Every browse-style "choose one" surface
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
extra action passes it through the picker's `actions` slot. The exception is the
recurring-scope chooser (*this / this and following / all*), which stays a
compact dialog — a two- or three-option decision reads better as a popup than as
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
or text-list dialogs aren't used at all.
Shared UI machinery lives in the `floret-kit` submodule and has
[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md);
changing it means a pull request against that repository plus a submodule bump
here.
## Commits & pull requests
Conventional commits, scoped to the area you touched:
```
fix(calendars): keep an event's own calendar when it is switched off
feat(month): pull-to-expand the split view (#38)
docs(architecture): record what the second review pass changed
```
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
`revert`. Reference the issue in the subject or the body. Keep commits small —
small commits revert cleanly, which matters more here than a tidy history.
If your change is user-visible, add an entry under `## [Unreleased]` in
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
what changed *for the person using the app*, and why, not what changed in the
code. Link the issue and add its reference at the bottom of the file. It may get
reworded when the release is cut, so don't agonise over it.
Please don't commit planning or design documents. Code, tests, architecture notes
and the changelog land; the reasoning belongs in the commit message and the
issue.
## Licence
Calendula is [MIT](LICENSE). By contributing you agree your changes ship under
the same licence.

10
Gemfile
View File

@@ -1,10 +0,0 @@
source "https://rubygems.org"
# fastlane is used ONLY to upload the release bundle to Google Play
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
# it never runs on a PR — only in release.yaml's `play` job.
#
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
gem "fastlane", "2.237.0"

101
README.md
View File

@@ -18,18 +18,15 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
</p>
</div>
@@ -71,38 +68,24 @@ database, no sync stack reinvented.
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- English and German UI plus community translations (Spanish, French, Italian,
Polish, and more in progress), per-app language setting — and [open to more
languages](#-translations)
- German and English UI, per-app language setting — and [open to community
translations](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
## 📦 Install
Pick whichever channel you already use — they all install the same app:
| Channel | Updates | Notes |
| --- | --- | --- |
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
| [Google Play](#google-play-coming-soon) | — | Coming soon |
| [Build from source](#build-from-source) | Whenever you build | Full control |
### F-Droid (recommended)
Calendula is on the **official [F-Droid](https://f-droid.org) repository**
just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
F-Droid rebuilds from source on its own schedule, so a new version usually
shows up there a few days after release.
### Self-hosted repo (latest builds)
### Self-hosted F-Droid repo (fastest updates)
Every release is built, signed, and published to a self-hosted F-Droid
repository as part of the release pipeline, so it lands there first. Add it once
and your F-Droid client handles updates from then on:
New versions are built, signed, and published to a self-hosted repository the
moment each tag lands — usually a few days ahead of the official repo, which
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
@@ -117,71 +100,19 @@ and your F-Droid client handles updates from then on:
2. Refresh, search for **Calendula**, install.
### Codeberg release / Obtainium
If you'd rather not use F-Droid at all, every release is also published on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
and install it directly.
For automatic updates from that channel, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
one appears.
### Google Play (coming soon)
Calendula is on its way to Google Play as an additional channel. It isn't live
yet — this section gets a link once it is. Play builds will be signed with
Google's key rather than mine, so switching between Play and any other channel
will require an uninstall.
> **Testers wanted.** Play requires a round of closed testing before the app can
> go public, and I'm still looking for testers. If you'd like to help, email
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
> Google account address you want to use — that address is what I need to add you
> to the closed test.
### Build from source
The build is a plain Gradle build with no proprietary dependencies — see
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
the same signing key, so you can switch freely between them without
reinstalling.</sub>
Both channels share the same signing key, so you can switch between them
without reinstalling. Or build from source — see below.
## 📚 Documentation
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
## 🤝 Contributing
Bug reports, ideas, and patches are all welcome on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
The short version: **start with an issue.** Features get a yes-or-no before they
get code, and both features and bugs are assigned a milestone whose
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
skip straight to a pull request. Translations don't go through pull requests at
all — [Weblate owns them](#-translations).
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
workflow, the build (note the `floret-kit` submodule), and the architectural
rules a change is reviewed against.
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
## 🌍 Translations
Calendula ships in English and German, with community translations in Arabic,
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
degrees of completeness — partial is fine, untranslated strings simply fall back
to English. You're warmly invited to add or finish your language. Translations
are managed on a self-hosted **Weblate**:
Calendula ships in German and English, and you're warmly invited to add your
language. Translations are managed on a self-hosted **Weblate**:
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**

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 = 21701
versionName = "2.17.1"
versionCode = 21600
versionName = "2.16.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -47,9 +47,12 @@ class CalendulaApp : Application() {
}
/**
* Bring reminder delivery up with the process (#75): a scan re-arms whatever
* the system dropped and posts what a missed alarm still owes, then the
* provider watch keeps edits re-planned. The daily worker is the backstop.
* Bring reminder delivery up with the process (#75). The app plans and arms
* its own reminder alarms now, so launch is one of the moments that has to
* re-check them: a scan re-arms whatever the system dropped, posts anything
* a missed alarm still owes, and starts watching the provider so an edit
* re-plans without waiting for the next pass. The daily worker is the
* backstop for a device that drops the alarm with no reboot to announce it.
*/
private fun startReminderDelivery() {
val deps = EntryPointAccessors.fromApplication(
@@ -62,9 +65,12 @@ class CalendulaApp : Application() {
}
/**
* Flush any calendar switch-off not yet written to `Calendars.VISIBLE`,
* including the set inherited from the retired app-local model (#75). A
* no-op in the steady state; `RootScreen` re-runs it after a later grant.
* Flush any calendar switch-off the app hasn't been allowed to write into
* the system's `Calendars.VISIBLE` yet — including the retired app-local
* "disabled calendars" set the upgrade inherits (#75). A no-op on a fresh
* install and in the steady state; a launch without the calendar permission
* leaves the set pending, and `RootScreen` runs it again once the app comes
* up holding it — whichever way it was granted.
*/
private fun reconcileCalendarVisibility() {
val deps = EntryPointAccessors.fromApplication(
@@ -95,8 +101,10 @@ class CalendulaApp : Application() {
/**
* Re-arm (or cancel) the daily special-dates reconcile from the saved
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
* ON_RESUME trigger, so no immediate run is needed here.
* settings on every launch — like [reconcileAutoBackup], this cancels
* orphaned work after the feature is turned off and re-schedules after a
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
* refresh, so no immediate run is needed here.
*/
private fun reconcileSpecialDates() {
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)

View File

@@ -73,8 +73,9 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
* the reminder actually fires on.
* detail screen. Delegates to [allDayLeadDays] so what is displayed is the day
* the reminder actually fires on; see there for how an encoded row is told from
* a plain one written by another calendar app.
*/
internal fun fromProviderAllDayMinutes(
rawMinutes: Int,

View File

@@ -117,15 +117,19 @@ interface CalendarDataSource {
/**
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
* app's one visibility model (#75), which also gates its reminders. One of
* the three columns the platform documents as app-writable, and device-local.
* app's one visibility model (#75). `VISIBLE` also gates the provider's own
* reminder scheduling, so switching a calendar off here is what actually
* stops its notifications; switching it on is what brings them back.
* Writable by a plain app (one of the three columns the platform documents
* as such) and device-local — no sync adapter pushes it anywhere.
*/
fun setCalendarVisible(id: Long, visible: Boolean)
/**
* Whether one calendar is switched on at system level, without reading every
* row — for the reminder gate, which sees only a calendar id. Null when the
* answer can't be had: no row, or no read permission.
* Whether one calendar is currently switched on at system level, without
* reading every row — for the reminder gate, which sees a calendar id and
* nothing else. Null when the answer can't be had: no row (the calendar was
* deleted) or no read permission.
*/
fun isCalendarVisible(id: Long): Boolean?
@@ -401,11 +405,14 @@ class AndroidCalendarDataSource @Inject constructor(
}
/**
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI, one
* calendar per call. Both parts are load-bearing:
* `CalendarProvider2.updateInTransaction` skips the dirty marking and the
* `checkNextAlarm()` reschedule unless the selection is `_id=…`, and the
* plain URI is what makes the write apply to synced calendars too.
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI,
* one calendar per call. Both parts are load-bearing:
* `CalendarProvider2.updateInTransaction` short-circuits to a raw database
* update unless the selection is `_id=…`, skipping the dirty marking *and*
* the `checkNextAlarm()` reschedule — i.e. an `_id IN (…)` batch would write
* the flag but never re-arm the reminder alarms this write exists to
* trigger. The sync-adapter URI is avoided so the write also applies to
* synced calendars, which is where the bug bites.
*/
override fun setCalendarVisible(id: Long, visible: Boolean) {
val values = ContentValues().apply {

View File

@@ -31,7 +31,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
// NULL is treated as syncing — the harmless default.
// A provider that leaves the column NULL is treated as syncing — the
// harmless default, since this flag only ever holds the one-shot
// visibility migration back from switching a calendar on.
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
)

View File

@@ -39,12 +39,16 @@ interface CalendarRepository {
suspend fun deleteCalendar(id: Long)
/**
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which also gates
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
* at a time, in order; a failure part-way leaves the earlier writes standing.
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which is also what
* turns the provider's reminder scheduling for them on or off — see
* [CalendarDataSource.setCalendarVisible]. Each calendar is written on its
* own, in order; a failure part-way leaves the earlier writes standing (the
* observer reports whatever actually landed).
*
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]),
* where it filters events and reminders just the same until it can be
* written.
*/
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)

View File

@@ -68,17 +68,23 @@ class CalendarRepositoryImpl @Inject constructor(
/**
* Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set.
* notifications, plus every change to the pending switch-off set (an id
* leaves it as its `VISIBLE` write lands, which changes what is shown).
* [calendarsSnapshot] keeps the two in step.
*/
private fun visibilityTicks(): Flow<Unit> = merge(
ticks.onStart { emit(Unit) },
// drop(1): the current value is already covered by the tick above.
// The current value is already covered by the tick above; only later
// changes re-query (the set is deduped, so an unrelated DataStore write
// doesn't).
prefs.pendingDisabledCalendarIds.drop(1).map {},
)
// A switch-off not yet written to the provider is folded into the flag
// itself, so every consumer reads one visibility (#75). The reconciler goes
// to the data source directly — it needs the provider's own answer.
// A switch-off the app hasn't been allowed to write yet is folded into the
// flag itself, so every consumer — the Settings switch, the filter sheet,
// the form and import pickers, the widgets — reads one visibility and can't
// disagree with what the user just tapped. The reconciler reads the data
// source directly, because it needs the provider's own answer.
override fun calendars(): Flow<List<CalendarSource>> =
visibilityTicks().reQuery {
val calendars = calendarsSnapshot()
@@ -88,13 +94,19 @@ class CalendarRepositoryImpl @Inject constructor(
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
}
}
// Collapse re-emissions that carry an identical list (see
// [instances]).
.distinctUntilChanged()
.flowOn(io)
// Instances are filtered by the system's VISIBLE flag the switch-offs
// still waiting to be written to it the app-side hidden set from the
// filter sheet. [calendars] stays unfiltered so those screens can list and
// re-enable invisible calendars.
// Instances are filtered by the system's per-calendar VISIBLE flag the
// switch-offs still waiting to be written to it the app-side hidden set:
// an event is dropped when the user switched its calendar off in Settings →
// Calendars (which also stops the provider scheduling its reminders) *or*
// hid it in the filter sheet. Re-runs when the provider ticks — writing
// VISIBLE notifies, so switching a calendar updates every view — or when
// either set changes. [calendars] stays unfiltered so those screens can list
// and re-enable invisible calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine(
visibilityTicks().reQuery {
@@ -115,8 +127,10 @@ class CalendarRepositoryImpl @Inject constructor(
if (excluded.isEmpty()) queried.instances
else queried.instances.filterNot { it.calendarId in excluded }
}
// Any DataStore edit re-emits the hidden set even when unchanged;
// collapse those so views don't re-render for them.
// Any DataStore edit re-emits the hidden set even when it is
// unchanged (e.g. writing the last-used calendar), which would
// re-surface an identical list — collapse those so views don't
// re-render for them.
.distinctUntilChanged()
.flowOn(io)
@@ -137,14 +151,21 @@ class CalendarRepositoryImpl @Inject constructor(
private var cachedCalendars: List<CalendarSource> = emptyList()
/**
* The calendar list for the current tick, queried once and shared, so every
* open view doesn't pay for its own `Calendars` query and all of them see
* one snapshot.
* The calendar list for the current tick, queried once and shared. Every
* open view collects [calendars] *and* filters its instances by visibility,
* which used to cost one full `Calendars` query each per tick. Reusing a
* single read also keeps them consistent: within a tick, what a screen lists
* and what its events are filtered against can't come from two snapshots.
*
* An empty result is never cached — that is what a read without the calendar
* permission returns, and the grant itself doesn't notify the provider. The
* pending switch-off set keys the cache alongside the tick, because an id
* leaves it before the invalidating observer is dispatched.
* An empty result is never cached — it is what a read without the calendar
* permission returns, and the grant itself doesn't notify the provider.
*
* The pending switch-off set keys the cache alongside the tick. An id leaves
* that set the moment its `VISIBLE` write lands, while the observer that
* would invalidate the snapshot is only dispatched through the main looper
* afterwards — so a snapshot taken while the id was still pending, read
* against the set that no longer holds it, would report the calendar as *on*
* again and re-admit exactly the events being hidden.
*/
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
val current = generation.get()

View File

@@ -21,15 +21,25 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
* visibility model, and the standing drain for switch-offs made without
* Keeps the app's pending "switched off" set (see
* [CalendarPrefs.pendingDisabledCalendarIds]) and the system's
* `Calendars.VISIBLE` in step — the fold-in of the retired app-local visibility
* model (#75), and the standing drain for switch-offs made without
* `WRITE_CALENDAR`.
*
* Runs on every launch and on every calendar-permission grant; a no-op once the
* pending set is empty and the notice is settled. Only ever hides (see
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
* system-hidden calendar arms the one-time explanatory notice.
* Runs on every launch, and again whenever the app comes up holding the calendar
* permission — a grant made on Android's own app-settings screen never reaches
* the permission screen's callback. It is a no-op whenever the pending set is
* empty and the notice has been settled, which
* is the steady state: each entry is written and dropped individually, so a run
* that dies part-way resumes exactly where it stopped and never re-applies a
* write the user has since undone by hand.
*
* The reconciliation only hides (see [calendarVisibilityPlan]). Calendars hidden
* at system level stay hidden, and on an *upgraded* install the first run that
* sees one arms the one-time notice explaining why Calendula no longer lists
* their events. A fresh install never had the old behaviour, so it retires that
* notice unshown — every other device ships with something hidden.
*/
@Singleton
class CalendarVisibilityReconciler @Inject constructor(
@@ -40,30 +50,43 @@ class CalendarVisibilityReconciler @Inject constructor(
) {
suspend fun run() = withContext(io) {
// The DataStore reads sit inside the guard too: this runs in a bare
// application-scope coroutine, so an IOException from a damaged
// preferences file would take the process down on every launch.
// Everything, the DataStore reads included, sits inside the guard: this
// runs in a bare application-scope coroutine with no exception handler,
// so an IOException from a damaged preferences file would otherwise take
// the process down on every launch.
try {
// Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade.
// A fresh install has no retired model behind it — nothing to
// migrate, and nothing to explain. Settled ahead of the permission
// gate so an update installed before the first grant can't make a
// first run look like an upgrade afterwards.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null
// The steady state, and every run after the first: nothing left to
// drain and nothing left to decide, so don't pay for the query.
if (pending.isEmpty() && noticeSettled) return@withContext
val calendars = dataSource.calendars()
// Empty means "couldn't read" (null cursor), not "no calendars".
// Both decisions below are one-way, so leave them to the next run.
// An empty read means "couldn't read", not "no calendars": the data
// source turns a null cursor — a provider momentarily unavailable —
// into an empty list. Both decisions below are one-way, so taking
// that reading as the truth would drop the whole pending set without
// ever writing VISIBLE = 0 (switching the user's calendars back on,
// events and reminders with them) and settle the notice as "nothing
// to explain". Leave both to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext
}
val plan = calendarVisibilityPlan(calendars, pending)
// Already off, or gone from the device — nothing to write, so let
// those ids leave the pending set with the rest.
prefs.removePendingDisabledCalendarIds(plan.settled)
// One calendar per write: the provider skips its reminder-alarm
// reschedule for anything but a single-id update (see
// [CalendarDataSource.setCalendarVisible]).
// [CalendarDataSource.setCalendarVisible]). Dropping each id as it
// lands keeps a part-applied run resumable.
for (id in plan.hide) {
dataSource.setCalendarVisible(id, false)
prefs.removePendingDisabledCalendarIds(setOf(id))
@@ -77,7 +100,9 @@ class CalendarVisibilityReconciler @Inject constructor(
/**
* Settle the one-time notice: [pending] arms it, false retires it unshown.
* Answered once and stored either way, so it can't resurface later.
* Answered once, by whichever run can answer it first; the answer is stored
* either way, so the notice can't resurface later, when the same state would
* no longer be news to the user.
*/
private suspend fun settleNoticeOnce(pending: Boolean) {
if (prefs.visibilityNoticePending.first() != null) return
@@ -86,7 +111,10 @@ class CalendarVisibilityReconciler @Inject constructor(
/**
* Whether this install has ever run an earlier version. The notice explains
* a behaviour change, so a first install has nothing to announce.
* a change to behaviour the user has already seen, so a first install has
* nothing to announce — and hidden calendars are the *norm* on a fresh
* device (a second account's, "Holidays in …", a subscribed calendar), which
* would otherwise put a changelog dialog in front of a first-run user.
*/
private fun isUpgradeInstall(): Boolean = try {
@Suppress("DEPRECATION")

View File

@@ -29,8 +29,10 @@ class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
// unrelated write would otherwise re-emit an identical set.
// Both id sets are deduped: the store is shared with SettingsPrefs, so every
// unrelated write (a settings toggle, the last-used calendar) re-emits an
// identical set otherwise — and a change to the pending set now costs a
// fresh provider read in CalendarRepositoryImpl.
val hiddenCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
@@ -40,13 +42,17 @@ class CalendarPrefs @Inject constructor(
}
/**
* Switch-offs the provider does not know about yet, because writing
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
* retired app-local model's set, from the same key.
* Calendars switched off in Settings → Calendars that the provider does not
* know about yet. That switch writes the system's `Calendars.VISIBLE` (#75),
* which needs `WRITE_CALENDAR` — a user who granted read-only keeps their
* choice here instead, and so does everyone upgrading from the retired
* app-local model, whose set is read straight back out of the same key.
*
* Honoured as a display and reminder filter while non-empty, but not a
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may.
* Honoured as a display and reminder filter for as long as it is non-empty,
* so an un-flushable switch still does what the user asked. Not a second
* visibility model: `CalendarVisibilityReconciler` drains it into the
* provider entry by entry the moment the app may write, and nothing ever
* adds to it while it may.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
@@ -56,8 +62,9 @@ class CalendarPrefs @Inject constructor(
editPendingDisabled { it + ids }
/**
* Drop [ids] from the pending set, one at a time as the reconciler flushes
* them, so a run that fails part-way never re-applies what already landed.
* Drop [ids] from the pending set one id at a time as the reconciler
* flushes it, so a run that fails part-way never re-applies what already
* landed (and can't undo a switch the user has since flipped by hand).
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it - ids.toSet() }

View File

@@ -10,13 +10,19 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* How far reminder delivery has got — the one number replacing the provider's
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
* watermark and up to now, then moves it to now, so a scan running twice cannot
* post twice while a late one still catches up.
* How far reminder delivery has got. One number, and it replaces everything the
* provider's `CalendarAlerts.STATE` used to do for us (#75).
*
* Unset means "never scanned", not zero: the first scan after an install would
* otherwise treat every reminder since the epoch as overdue.
* A scan posts the reminders whose moment falls after this watermark and up to
* now, then moves it to now. That single rule gives both halves of what the
* retired path got from the provider: a scan that runs twice cannot post the
* same reminder again, and a scan that runs *late* — after a reboot, an app
* update or a doze window swallowed the alarm — still posts everything the
* missed alarm would have.
*
* Unset means "never scanned". It is deliberately not treated as zero: the first
* scan after an install or an upgrade would otherwise consider every reminder
* since the epoch overdue and bury the user in notifications.
*/
@Singleton
class ReminderStatePrefs @Inject constructor(

View File

@@ -93,9 +93,13 @@ class ReminderActionReceiver : BroadcastReceiver() {
private const val EXTRA_ALL_DAY = "all_day"
/**
* An explicit intent to this receiver carrying [alert] as extras. The
* data URI duplicates no information but is what keeps two reminders'
* `PendingIntent`s apart — `filterEquals` never compares extras.
* An explicit intent to this receiver carrying [alert] as extras.
*
* The data URI names the reminder and carries no information the extras
* don't. It is what actually keeps two reminders' `PendingIntent`s
* apart: `filterEquals` compares action, component, data, type and
* categories — never extras — so without it a request-code collision
* would have them share one alarm and one payload.
*/
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply {
@@ -112,9 +116,13 @@ class ReminderActionReceiver : BroadcastReceiver() {
}
/**
* A stable request code per (alert, action), so one notification's
* PendingIntents stay distinct. The shift keeps the action slot intact;
* the top bits it drops are separated by [intent]'s per-reminder URI.
* A stable request code per (alert, action) so one notification's
* PendingIntents stay distinct and don't clobber each other.
*
* The key is a hash now rather than a small row id, so the shift is what
* keeps the action slot intact; the top three bits it drops can make two
* *different* reminders share a code, which the per-reminder data URI in
* [intent] separates.
*/
fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) {

View File

@@ -19,10 +19,17 @@ internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
* exists at a time, for the earliest reminder ahead; every firing re-scans and
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
* withholds the capability (API 3132 with the permission revoked).
* Holds the app's own wake-up for the next reminder — the half of delivery that
* used to be the provider's (#75).
*
* Exactly **one** alarm exists at a time, for the earliest reminder still ahead.
* Every firing re-scans and re-arms, so a reminder added, moved or deleted in
* between is picked up on the next pass instead of needing an alarm per reminder
* to be kept in sync with the provider's tables.
*
* A reminder that lands late is a broken reminder, hence an *exact* alarm; the
* inexact allow-while-idle fallback only applies where the OS withholds the
* capability (API 3132 with the user's permission revoked).
*/
@Singleton
class ReminderAlarmScheduler @Inject constructor(

View File

@@ -4,9 +4,14 @@ import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
/**
* One reminder as the notification layer needs it: what to show, and the stable
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
* `CalendarAlerts` row to take an id from (#75).
* [key] that identifies it across a reboot, a re-scan and a reinstall.
*
* [key] used to be the `CalendarAlerts` row id. It is now derived from the
* reminder itself (see [PlannedReminder.key]) because there is no row any more —
* in-house delivery reads `Instances` and `Reminders` and owns the alarm (#75).
* Everything downstream only ever needed it to be stable and unique, which it
* still is: it keys the notification tag, so a reminder posted twice replaces
* itself instead of stacking, and it keys the snooze/dismiss `PendingIntent`s.
*/
data class ReminderAlert(
val key: Long,

View File

@@ -9,11 +9,16 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* The read side of in-house reminder delivery: occurrences and their reminder
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
* which cannot be assumed to be written (#75).
* The read side of in-house reminder delivery: occurrences and the reminder
* offsets hanging off them, straight out of the provider's own tables.
*
* An interface so [ReminderScanner] can be exercised on the JVM.
* Deliberately *not* `CalendarAlerts`. That table is the provider's own working
* copy of this same information, and the whole point of #75's second round is
* that we can no longer assume it gets written. `Instances` and `Reminders` are
* plain data the app already reads everywhere else.
*
* An interface so [ReminderScanner] can be exercised on the JVM without a
* ContentResolver, in the shape the rest of `data/calendar` uses.
*/
interface ReminderInstanceSource {
@@ -41,9 +46,11 @@ class ProviderReminderInstanceSource @Inject constructor(
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
// `visible` is the calendar's system flag, which the app's one visibility
// model writes (#75) — a switched-off calendar must not plan reminders.
// The status clause mirrors CalendarDataSource.instances: a cancelled
// single occurrence of a series is a real row, and NULL means "normal",
// so a bare `!= CANCELED` would drop every ordinary event.
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
@@ -71,8 +78,8 @@ class ProviderReminderInstanceSource @Inject constructor(
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
if (eventIds.isEmpty()) return emptyMap()
val out = mutableMapOf<Long, MutableList<Int>>()
// Batched: the ids go into the selection literally, and an unbounded
// `IN (...)` would grow the SQL past what SQLite takes.
// Batched because the ids go into the selection literally; an unbounded
// `IN (...)` on a busy calendar would grow the SQL past what SQLite takes.
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,

View File

@@ -15,8 +15,13 @@ import java.util.concurrent.TimeUnit
/**
* The backstop under the alarm: a daily scan that runs whether or not the alarm
* survived. Finds nothing to do in the steady state; it exists for the device
* that quietly drops the alarm without a reboot to announce it (#75).
* survived.
*
* The scan alarm re-arms itself at most a day out, so in the steady state this
* finds nothing to do. It exists for the case the whole feature is about — a
* device that quietly drops the alarm without a reboot to announce it. The
* scheduling half of reminder delivery must not have a single point of failure,
* which is exactly what the provider's broadcast turned out to be.
*/
object ReminderMaintenanceScheduler {
@@ -50,8 +55,8 @@ class ReminderMaintenanceWorker(
.scan()
Result.success()
} catch (e: Exception) {
// The scan swallows its own failures, so anything reaching here is the
// entry point itself a retry will not mend it.
// The scan swallows its own failures; anything reaching here is the
// entry point itself, which a retry will not mend. Never fail the chain.
Log.w(TAG, "Reminder maintenance scan failed", e)
Result.success()
}

View File

@@ -54,10 +54,12 @@ class ReminderNotifier @Inject constructor(
}
/**
* The single choke point for "this calendar is switched off", covering the
* two paths that reach [post] around the scan's own filter: a snooze armed
* before the switch-off, and a read-only install whose switch lives in
* [CalendarPrefs].
* The single choke point for "this calendar is switched off". The scan
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
* it: a snooze re-shown from its own alarm, armed before the calendar was
* switched off, and a read-only install whose switch lives app-side
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
* rather than in either receiver.
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
@@ -121,7 +123,7 @@ class ReminderNotifier @Inject constructor(
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
}
// Handled either way — a retry hits the same revoked permission.
// Handled either way: re-running it would hit the same revoked permission.
return true
}
@@ -140,7 +142,8 @@ class ReminderNotifier @Inject constructor(
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
// Shares the per-(alert, action) request-code scheme with the buttons.
// Shares the per-(alert, action) request-code scheme with the buttons, so
// the key's wider value range can't collide one notification's intents.
/* requestCode = */ ReminderActionReceiver.requestCode(
alert, ReminderActionReceiver.ACTION_OPEN,
),

View File

@@ -33,9 +33,13 @@ import javax.inject.Singleton
/**
* One pass of in-house reminder delivery: read what is planned, post what has
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
* is owed, not the trigger.
* come due, and arm the next wake-up.
*
* This is the whole loop. Every trigger — the alarm firing, boot, a clock or
* timezone change, an edit landing in the provider, the app starting, the daily
* safety net — runs the same [scan], so there is a single path to reason about
* and no ordering between triggers to get wrong. Re-running it is always safe:
* the watermark in [ReminderStatePrefs] decides what is owed, not the trigger.
*/
@Singleton
class ReminderScanner @Inject constructor(
@@ -64,7 +68,8 @@ class ReminderScanner @Inject constructor(
try {
runScan()
} catch (e: SecurityException) {
// Permission revoked mid-flight; the next grant re-scans.
// The calendar permission was revoked mid-flight. Nothing to
// re-arm and nothing to recover from — the next grant re-scans.
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
} catch (e: Exception) {
Log.w(TAG, "Reminder scan failed", e)
@@ -77,16 +82,18 @@ class ReminderScanner @Inject constructor(
if (!hasReadCalendar()) return
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving
// so switching them back on doesn't replay the backlog.
// Reminders off: drop the wake-up, but keep the watermark moving so
// switching them back on doesn't replay everything missed meanwhile.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach into the past too: an all-day "at time of event" encodes to a
// negative offset, and a catch-up pass needs the occurrences it missed.
// Reach a little into the past as well: an event already under way can
// still owe a reminder (an all-day "at time of event" encodes to a
// negative offset, which fires after begin), and a catch-up pass needs
// to see the occurrences whose moment it missed.
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders(
instances = occurrences,
@@ -105,8 +112,8 @@ class ReminderScanner @Inject constructor(
if (notifier.canPost()) {
schedule.due.forEach { notifier.post(it.toAlert()) }
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
// Advance regardless of whether anything could be posted: a user who
// muted notifications is not owed a backlog when they unmute.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
@@ -116,9 +123,13 @@ class ReminderScanner @Inject constructor(
) == PackageManager.PERMISSION_GRANTED
/**
* Re-scan when the provider changes, so a saved or deleted event re-arms the
* alarm at once. Debounced, since a single save lands as several
* notifications. Process-lifetime only; other triggers cover the rest.
* Re-scan when the provider changes, so an event saved or deleted in the app
* re-arms the alarm immediately rather than waiting for the next pass.
*
* Debounced: a single save writes the event, its reminders and its
* attendees, and each lands as its own notification. Only useful while the
* process is alive — every other trigger covers the rest, which is why
* nothing here needs to survive it.
*/
fun startWatchingProvider() {
if (watching) return
@@ -148,8 +159,9 @@ class ReminderScanner @Inject constructor(
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
/**
* Never wait longer than a day for the next pass: it rolls the lookahead
* window forward and re-arms an alarm the system may have dropped.
* Never wait longer than a day for the next pass, even with nothing
* pending: it rolls the lookahead window forward and re-arms an alarm
* the system may have dropped.
*/
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000

View File

@@ -11,13 +11,22 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
* alarm, boot and package-replaced (both wipe pending alarms), and time or
* timezone changes (both move reminders relative to the armed alarm). All do the
* same thing, since [ReminderScanner.scan] is idempotent.
* Every reason to re-run a reminder scan that arrives from outside the process.
*
* Exported for the system broadcasts; an early scan triggered by another app is
* harmless.
* All of them do the same thing, because [ReminderScanner.scan] is idempotent
* and works out what is owed from its watermark rather than from why it was
* called:
*
* - **our own alarm** ([ACTION_SCAN]) — the ordinary case, a reminder is due;
* - **boot** and **package replaced** — both wipe pending alarms, so the app has
* to re-arm or reminders stop silently, which is the failure #75 is about;
* - **time and timezone changes** — they move every reminder relative to the
* armed alarm, and an all-day reminder's fire hour is recomposed in the
* current zone, so both need a fresh plan.
*
* Exported because the system broadcasts arrive from outside. [ACTION_SCAN] is
* ours and always sent as an explicit intent; another app triggering a scan
* early would only make it re-read the provider and re-arm, which is harmless.
*/
@AndroidEntryPoint
class ReminderScheduleReceiver : BroadcastReceiver() {
@@ -25,9 +34,10 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
@Inject lateinit var scanner: ReminderScanner
override fun onReceive(context: Context, intent: Intent) {
// Checked despite every action doing the same thing: the receiver is
// exported and the broadcasts it takes are protected, so any other
// action did not come from where it claims to.
// Every action here does the same thing, but the filter still has to be
// checked: the receiver is exported, and the system broadcasts it takes
// are protected, so an intent arriving with any other action did not
// come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {

View File

@@ -10,13 +10,17 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
* from [ReminderAlarmScheduler]'s single moving scan alarm: a snooze is pinned
* to one reminder and has to outlive the watermark moving past it, so it carries
* the reminder in its own intent.
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
*
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
* exact-alarm capability (API 3132 with the permission revoked).
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
* snooze is pinned to one reminder at a time the user chose, while the scan
* alarm is a single moving wake-up for whatever comes next. A re-show also has
* to outlive the scan's watermark moving past that reminder, so it carries the
* reminder in its own intent rather than re-deriving it.
*
* 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(
@@ -36,8 +40,8 @@ class ReminderSnoozeScheduler @Inject constructor(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 3132); an inexact wake is the best
// available without nagging for SCHEDULE_EXACT_ALARM.
// 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,
)

View File

@@ -1,13 +1,15 @@
package de.jeanlucmakiola.calendula.domain
/**
* The ways a calendar can behave unlike a plain, writable one — each a reason it
* is missing from the event and import pickers (#76).
* The ways a calendar can behave unlike a plain, writable one — each of them a
* reason it is missing from the event and import pickers, and each of them
* something the app knows and used to keep to itself (#76).
*/
enum class CalendarStateLabel {
/**
* A special-dates mirror the app fills from contacts. Writable and visible,
* yet no event target: anything authored here is deleted by the next sync.
* yet no event target: anything authored here is deleted by the next sync,
* which is why it is the one exclusion with nothing else to give it away.
*/
MANAGED,
@@ -19,17 +21,22 @@ enum class CalendarStateLabel {
}
/**
* Whether the account keeps this calendar's events off the device
* (`Calendars.SYNC_EVENTS = 0`) — empty by construction. Device-local calendars
* are excluded: nothing syncs them by definition, and one from another app can
* hold real events at `sync_events = 0`.
* Whether the account this calendar belongs to keeps its events off the device
* (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows
* simply aren't here, so nothing can display them and no reminder can fire.
*
* Device-local calendars are excluded deliberately. Nothing syncs them by
* definition, so the flag says nothing about them, and a local calendar from
* another app can hold real events at `sync_events = 0` — the same unsoundness
* that made the #75 migration guard wrong.
*/
val CalendarSource.isNotSynced: Boolean
get() = !syncsEvents && !isLocal
/**
* Whether a visibility switch on this calendar can change anything the user
* would see — it can't for a non-syncing one, with no events on the device.
* would see. It can't for a non-syncing one: there are no events on the device
* to reveal, so the switch would be a control that does nothing.
*/
val CalendarSource.hasVisibilitySwitch: Boolean
get() = !isNotSynced
@@ -37,9 +44,18 @@ val CalendarSource.hasVisibilitySwitch: Boolean
/**
* Whether this calendar can be offered as a target for a new or imported event.
* The one predicate behind both pickers, so the states [CalendarStateLabel]
* names on a manager row are exactly the states that keep a calendar out of them
* (#76). An event already living in an excluded calendar keeps it; the editor
* adds that calendar back to its picker.
* names on a manager row are exactly the states that keep a calendar out of
* them (#76):
*
* - read-only has nowhere to write;
* - switched off would hide the event the moment it was saved;
* - a managed mirror has the next contact sync delete it;
* - a non-syncing one never carries the event up to the account, and
* `CalendarProvider2` wipes the calendar's rows outright when the
* subscription is switched back on — a saved event is a dead end either way.
*
* This is the test for *targets*. An event already living in an excluded
* calendar keeps it; the editor adds that calendar back to its picker.
*/
val CalendarSource.isEventTarget: Boolean
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced

View File

@@ -12,16 +12,22 @@ data class CalendarVisibilityPlan(
}
/**
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
* what the retired app-local visibility model left behind (#75) — against the
* Reconcile [pendingDisabledIds] — calendars switched off in Settings →
* Calendars while the app could not write `Calendars.VISIBLE`, plus whatever
* the retired app-local visibility model left behind (#75) — against the
* calendars actually on the device.
*
* Only ever *hides*: switching a system-hidden calendar back on would un-hide it
* in every other calendar app too. Calendula follows the flag and explains
* itself once (see [hasSystemHiddenCalendars]).
* The plan only ever *hides*. Switching a calendar off is intent the user
* expressed in Calendula, so carrying it into the provider is fair. The other
* direction is deliberately absent: a calendar hidden at system level was hidden
* somewhere else (another calendar app, the account's own settings), and
* switching it back on would un-hide it there too *and* start firing reminders
* nobody asked for. Calendula follows that flag instead and explains itself once
* (see [hasSystemHiddenCalendars]).
*
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
* hidden, or gone from the device.
* [CalendarVisibilityPlan.settled] carries the ids that need no write — already
* hidden, or gone from the device. They leave the pending set exactly as a
* successful write would.
*/
fun calendarVisibilityPlan(
calendars: List<CalendarSource>,
@@ -32,7 +38,8 @@ fun calendarVisibilityPlan(
val settled = mutableSetOf<Long>()
for (id in pendingDisabledIds) {
val calendar = byId[id]
// Gone from the device, or already invisible — nothing to write.
// No row means the calendar is gone; already invisible means someone
// (us, on an earlier run) got there first. Either way: nothing to write.
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
}
return CalendarVisibilityPlan(hide = hide, settled = settled)
@@ -40,7 +47,10 @@ fun calendarVisibilityPlan(
/**
* Whether any calendar is switched off at system level without Calendula having
* asked for it — the condition the one-time notice explains.
* asked for it. Those calendars showed their events before the app adopted
* `Calendars.VISIBLE` as its one visibility model and no longer do, which is
* what the one-time notice explains — the alternative, switching them on, would
* reach into every other calendar app on the device.
*/
fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,

View File

@@ -13,9 +13,11 @@ data class CalendarSource(
val accountType: String,
val color: Int,
/**
* The system's `Calendars.VISIBLE` flag — the single visibility model,
* deciding both what Calendula shows and whether this calendar plans
* reminders (#75). The drawer's filter sheet is a separate in-app declutter.
* The system's per-calendar `Calendars.VISIBLE` flag — the single visibility
* model: it decides both what Calendula shows and whether the provider
* schedules this calendar's reminder alarms at all (#75). Settings →
* Calendars writes it; the drawer's filter sheet is a separate, purely
* in-app declutter that leaves reminders alone.
*/
val isVisibleInSystem: Boolean,
/**
@@ -45,8 +47,10 @@ data class CalendarSource(
val isManaged: Boolean = false,
/**
* Whether the provider keeps this calendar's events on the device
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
* nothing about device-local calendars, which can hold events with it off.
* (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]. For a
* synced account it means the events aren't stored locally at all, so the
* calendar reads as permanently empty; a device-local calendar another app
* created can hold events with the flag off, so it says nothing there.
* Read for the "not synced" row label (#76).
*/
val syncsEvents: Boolean = true,
@@ -73,9 +77,12 @@ data class EventInstance(
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
/**
* The zone this event's calendar dates live in: the device [zone] for timed
* events, UTC for all-day ones, whose midnights would otherwise shift day
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
* The zone this event's calendar dates live in. Timed events are resolved in the
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
* resolving them anywhere else shifts the day boundaries — east of UTC the end
* leaks onto the following day (#65), west of UTC the start pulls back onto the
* previous one (#82). Every surface that has to name an all-day event's date
* goes through here, so grid, agenda, detail and search cannot disagree.
*/
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
@@ -85,8 +92,9 @@ fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* The last calendar day this event occupies. An event ending exactly at midnight
* does not reach into that day, so resolve just before [EventInstance.end].
* The last calendar day this event actually occupies. An event ending exactly at
* midnight (all-day events end at the exclusive next-midnight) does not reach
* into that boundary day, so resolve the instant just before [EventInstance.end].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start

View File

@@ -10,18 +10,31 @@ import kotlinx.datetime.plus
/**
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
* kept to the shapes the picker can build; the provider stays the authority.
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
* instead of as words.
*
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
* start day doesn't exist in rather than clamping; a weekly rule repeats in
* blocks of `interval` weeks beginning on Monday (the default WKST, since
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
* [RecurrenceEnd.Until] is inclusive.
* A recurrence rule is the one place in the editor where a correct-sounding
* phrase can still mean something the user didn't intend — "every 2 weeks on
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
* themselves. This is a *preview*, deliberately kept to the shapes the picker
* can build; the provider remains the authority on what actually gets stored
* and expanded.
*
* Returns fewer than [limit] dates when the series ends first, and an empty list
* only when the rule yields nothing at all (an UNTIL before [start]).
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
* engine implements):
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
* rather than sliding to the month's last day: the 31st recurring monthly
* lands only in 31-day months, 29 February yearly only in leap years.
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
* with the week beginning on **Monday** — the RFC's default WKST, and the one
* that applies here because [toRRule] never writes a WKST part. The
* display-only "first day of week" preference deliberately doesn't enter into
* it, or the preview would disagree with the stored rule.
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
*
* Returns fewer than [limit] dates when the series ends first, and an empty
* list when the rule yields nothing at all (an UNTIL before the start).
*/
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
if (limit <= 0) return emptyList()
@@ -29,17 +42,15 @@ fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<Loc
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
val wanted = minOf(limit, maxCount)
if (wanted <= 0) return emptyList()
if (until != null && start > until) return emptyList()
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
// and let the walk skip anything landing on or before it.
val result = mutableListOf(start)
val result = mutableListOf<LocalDate>()
var period = 0
// Periods can yield nothing (a skipped 31st), so the cap counts periods
// examined rather than dates found.
// Periods that produce nothing (a skipped 31st, a weekday block whose picks
// all fall before the start) must not stall the walk, so the cap is on
// periods examined rather than on dates found.
while (result.size < wanted && period < MAX_PERIODS) {
for (date in occurrencesInPeriod(period, start)) {
if (date <= start) continue
if (date < start) continue
if (until != null && date > until) return result
result += date
if (result.size == wanted) return result
@@ -56,8 +67,8 @@ private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate):
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
RecurrenceFreq.Monthly -> {
val month = start.plus(period * interval, DateTimeUnit.MONTH)
// plus() clamps into the shorter month, but the rule skips such a
// period so a clamped date means "not this month".
// plus() clamps the day into the shorter month; the rule instead
// skips such a period, so a clamped date means "not this month".
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
}
RecurrenceFreq.Yearly ->
@@ -85,9 +96,13 @@ private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): L
}
/**
* Whether a run of [occurrences] starting at [start] leaves its starting year,
* i.e. whether showing them without a year would be ambiguous — a yearly rule
* would otherwise read as the same date repeated.
* Whether a run of [occurrences] starting at [start] leaves its starting year
* i.e. whether showing them without a year would be ambiguous.
*
* Day and month alone are enough for a rule that stays inside one year, and
* dropping the year keeps the line short. They are actively misleading
* otherwise: a yearly rule reads as the same date three times over, and a
* monthly one rolling past December gives no hint that it has.
*/
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
@@ -99,8 +114,9 @@ private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
private const val DAYS_PER_WEEK = 7
/**
* How many repetitions to examine before giving up: generous enough for the
* sparsest rule the picker can build, bounded so a rule whose occurrences all
* fall outside its own UNTIL can't spin.
* How many repetitions to examine before giving up. Generous enough for the
* sparsest rule the picker can build (29 February yearly with a 999-year
* interval is nonsense; 31st monthly needs at most a handful), and bounded so a
* rule whose occurrences all fall outside its own UNTIL can't spin.
*/
private const val MAX_PERIODS = 2_000

View File

@@ -8,8 +8,21 @@ import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
* Works out *when* each reminder has to fire and *which* ones are due, with no
* provider and no clock of its own — the whole decision layer of in-house
* reminder delivery (#75).
*
* Calendula used to leave both halves to the calendar provider: it scheduled the
* alarms, wrote the `CalendarAlerts` rows, and broadcast `EVENT_REMINDER` at the
* right moment. That chain is intact on stock Android but demonstrably not on
* every device — AOSP's own unbundled calendar carries three separate
* workarounds for OEMs that retarget the broadcast, or that only write the alert
* row at alert time. An app that can only *react* to that broadcast has no way
* to notice it never came.
*
* So the offsets in `CalendarContract.Reminders` are now read as data and turned
* into alarms we own. Everything here is pure: instances and reminder offsets in,
* fire instants out.
*/
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
@@ -33,8 +46,11 @@ data class PlannedReminder(
val alarmMillis: Long,
) {
/**
* Stable identity, keying the notification tag and the snooze/dismiss
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
* Stable identity of this reminder, derived from what defines it rather
* than from a provider row id (there is none any more). It keys the
* notification tag and the snooze/dismiss `PendingIntent`s, so it has to
* survive a reboot, a re-scan and a reinstall — the same reminder must land
* on the same notification instead of stacking a second one.
*/
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
@@ -59,10 +75,28 @@ private const val MINUTES_PER_DAY = 1_440
/**
* Pair every instance with each of its event's reminder offsets.
*
* Timed occurrences fire at `begin minutes`. All-day ones read the offset only
* for *which day* it means ([allDayLeadDays]) and take the hour from
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
* Duplicate offsets in [minutesByEvent] collapse.
* A **timed** occurrence is trivial: `begin` is an absolute instant, so
* `begin minutes` is exact by construction, in any timezone, across any DST
* boundary.
*
* An **all-day** occurrence is not, and taking the offset at face value is what
* makes reminders land at the wrong hour. Its `begin` is UTC midnight, and the
* stored offset is not a plain lead time — `AllDayReminderEncoding` folds the
* wanted wall-clock hour into it, sampled against *one* date's UTC offset. Fire
* at `begin minutes` and every occurrence in a different DST phase than the one
* that was sampled drifts by the offset delta, an hour early in one direction and
* an hour late in the other. Rows written by other apps carry no wall-clock at
* all — a conventional `1440` fires at UTC midnight, which is 01:00 or 02:00
* local in Berlin and the wrong day west of UTC.
*
* So the offset is only read for *which day* it means, via [allDayLeadDays], and
* the hour comes from [allDayTimeMinutes] — the one global "show all-day
* reminders at" setting — recomposed against each occurrence's own date in
* [zone]. 09:00 Berlin is then 09:00 Berlin on every occurrence, whatever the
* offset was when the row was written.
*
* [minutesByEvent] may hold duplicate offsets (two identical reminder rows on one
* event); they collapse, because they would otherwise fight over one notification.
*/
fun planReminders(
instances: List<ReminderEventInstance>,
@@ -90,15 +124,30 @@ private fun allDayDate(beginMillis: Long): LocalDate =
/**
* How many whole days before its occurrence a raw all-day offset means.
*
* Our own rows fold the wanted hour into the offset, so the local date of the
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
* time and taken at face value instead — unless the instant lands on the hour the
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
* encodings collide and the tie goes to our own reading.
* Normally the local date of the encoded fire instant answers it: our own rows
* fold the wanted hour into the offset, so that date *is* the day the reminder
* belongs to, whatever the offset was when it was written.
*
* Also used by
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
* display, so screen and notification agree.
* A plain multiple of 1440 is the exception. Those come from calendar apps that
* store a bare lead time against UTC midnight, and east of UTC both readings
* agree anyway — but west of UTC the instant falls on the previous local date,
* so the day count has to be taken at face value or it comes out one too many.
*
* Except when the row is one of ours after all: our offset lands on a multiple
* whenever the all-day hour equals the zone's UTC offset (20:00 in New York,
* 19:00 an hour further west, and so on), and reading those at face value fired
* them a day late — a "1 day before" arriving on the morning of the event. So
* the hour decides, not the shape of the number: an instant landing on the hour
* the setting names is ours. Within an hour or so of it counts, because a fixed
* offset written in one DST phase drifts by the offset delta in the other.
*
* The two are genuinely indistinguishable in that band — the encodings collide
* exactly there — so the tie goes to the reading that honours the lead time the
* user chose in this app.
*
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] calls
* this for display, so the notification arrives on the day the event screen says
* it will.
*/
internal fun allDayLeadDays(
rawMinutes: Int,
@@ -136,10 +185,17 @@ private fun allDayAlarmMillis(
/**
* Split [planned] into what is due now and when to wake up next.
*
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
* running twice cannot post twice while a late one still catches up. Reminders
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]` — a
* half-open watermark, so a scan triggered twice cannot post the same reminder
* twice, while a scan that runs late still catches everything the missed alarm
* would have posted. That catch-up is the point: an alarm dropped by a reboot,
* an app update or a doze window is recovered by the next scan rather than lost.
*
* A reminder whose event has already ended is dropped rather than posted late —
* see [isStillRelevant].
*
* [nextAlarmMillis] is capped at [horizonMillis] even when nothing is pending, so
* the scan re-runs at least that often and the lookahead window rolls forward.
*/
fun scheduleReminders(
planned: List<PlannedReminder>,
@@ -169,20 +225,31 @@ fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
* claims the present rather than replaying everything since the epoch; a
* watermark in the future (clock moved back) is clamped so it can't silence
* every reminder until real time catches up.
* The watermark a scan at [nowMillis] should measure against, given what the
* last one recorded.
*
* A first-ever scan ([lastScanMillis] `null`) claims the present, so an install
* or an upgrade onto in-house delivery does not treat every reminder since the
* epoch as overdue and bury the user in notifications. A watermark in the
* *future* — the clock was moved back, or the user travelled across the date
* line — is clamped for the mirror-image reason: left alone it would silence
* every reminder until real time caught up with it.
*/
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
/**
* How far ahead instances must be queried: the plain lookahead plus the longest
* reminder offset, so a "two weeks before" is planned before it comes due. The
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
* largest row in the whole provider, and a nonsense one would otherwise make
* every scan expand every series over years.
* How far ahead instances must be queried for [scheduleReminders] to see every
* reminder in time: the plain lookahead plus the longest offset any reminder row
* carries, so a "two weeks before" reminder is planned before it comes due
* instead of firing late (the limitation Etar's equivalent documents).
*
* The stretch is capped at [MAX_REMINDER_LEAD_MILLIS]. `maxReminderMinutes` is
* whatever the largest row in the whole provider says, across every calendar and
* whoever wrote it — an imported `TRIGGER:-P100W`, or a sync adapter writing
* nonsense, would otherwise make every scan (launch, every provider change, every
* alarm) expand every recurring series over years. A lead beyond the cap is not
* delivered; a year is far past anything the picker composes.
*/
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)

View File

@@ -149,8 +149,9 @@ fun CalendarHost(
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Backup & restore (#69) — hoisted like the manager, being driven by the
// calendar list rather than by preferences. Reached from both.
// Backup & restore (#69) — like the manager, driven by the calendar list
// rather than by preferences, so it is hoisted here instead of living as a
// Settings sub-section. Reached from Settings and from the manager.
var showBackup by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen:
@@ -485,8 +486,10 @@ fun CalendarHost(
)
}
// Declared last so it covers every overlay that can open it: Settings,
// both event forms, and the .ics import picker (#76).
// Calendar manager — declared last so it covers every overlay that can
// open it: Settings, both event forms, and the .ics import picker (#76).
// Coming back from it leaves the caller exactly as it was, with the
// calendar list already refreshed by the provider's notification.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
@@ -506,15 +509,12 @@ fun CalendarHost(
) {
BackupScreen(
onBack = { showBackup = false },
// Restore runs the normal .ics import, and both this screen and
// the manager that can have opened it are declared above the
// import overlays — so both have to step aside.
onImport = {
importUri = it
importForceMany = true
showBackup = false
showCalendars = false
},
// Restoring runs the normal .ics import, and that way round this
// screen has to step aside: declared above the import overlays,
// it would otherwise cover the screen it just asked for. Closing
// it hands the user back to whatever opened Backup once the
// import is done.
onImport = { importUri = it; importForceMany = true; showBackup = false },
)
}
}

View File

@@ -51,8 +51,8 @@ fun RootScreen(
)
}
// A launch scan already covers the app coming up with the permission, so
// only a grant made during this session owes a re-scan.
// Whether the app came up already holding it — a launch scan has covered
// that case, so only a grant made during this session owes a re-scan.
val grantedAtLaunch = remember { hasPermission }
val lifecycle = LocalLifecycleOwner.current.lifecycle
@@ -86,11 +86,12 @@ fun RootScreen(
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
// One-time explainer for the switch to the device's own calendar
// visibility (#75), armed by the reconciler.
// visibility (#75); armed by the reconciler, shown over the app.
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
// Runs on entry however the permission was granted, including via
// Android's app-settings screen (caught by the ON_RESUME above).
// Runs on entry however the permission was granted including from
// Android's app-settings screen, which only comes back through the
// ON_RESUME check above. Cheap once there is nothing left to do.
LaunchedEffect(Unit) {
visibilityNotice.reconcile()
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()

View File

@@ -92,27 +92,20 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026"), with the start's
* year shown too only when it differs from the end's.
*
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start end" form as well.
* The agenda's own header browses the whole month, so the month name is right
* there; the range picker previews the window an option opens *today*, which for
* "This month" is only the rest of it.
*/
fun agendaRangeWindowSummary(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
locale: Locale,
monthAsSpan: Boolean = false,
): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when {
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
range == AgendaRange.ThisMonth && !monthAsSpan ->
localizedDateFormatter(locale, "LLLLy").format(javaStart)
return when (range) {
AgendaRange.Day -> dayMonthYear.format(javaStart)
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"

View File

@@ -59,8 +59,10 @@ import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import java.time.LocalDate
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
// under several mimes, so accept the common set.
// SAF mime filter for the restore picker. `.ics` files reach us under several
// mimes depending on the source app (our own export uses text/calendar; others
// hand them out as octet-stream or text/plain), so accept the common set rather
// than hide valid backups behind an over-tight filter.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
@@ -68,8 +70,11 @@ private val RESTORE_MIME_TYPES = arrayOf(
)
/**
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
* Backup & restore (#69). Local calendars aren't synced anywhere, so a `.ics`
* export is their only safety net — that made it a data-safety feature hiding in
* the calendar manager, where nobody went looking. It now has its own Settings
* entry, sharing [CalendarsViewModel] with the manager (both are driven by the
* same calendar list).
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
* [onImport] hands a picked file to the app's normal .ics import flow.
@@ -87,24 +92,29 @@ fun BackupScreen(
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Export covers local calendars only; managed special-dates mirrors are
// rebuilt from contacts. Restore can target anything the import picker offers.
// Export covers the user's own local calendars (managed special-dates
// mirrors don't count — they are rebuilt from contacts). Restore can target
// any calendar the import picker would offer, so its availability is broader.
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
// Exports everything eligible (null); the per-calendar selector owns its
// own launcher.
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// Restore runs the picked file through the normal .ics import flow.
// SAF "open document" picker for restoring events from a .ics file. The
// picked Uri is handed up to the host, which runs it through the same import
// flow as an externally opened .ics (parse, dedup by UID, target picker).
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// The VM persists the write grant so background runs can keep writing.
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
@@ -138,12 +148,15 @@ fun BackupScreen(
HintText(stringResource(R.string.calendars_backup_hint))
if (exportable.isNotEmpty()) {
// One connected card: the one-time export on top, restore under it,
// then automatic backup (and its folder/interval rows when on).
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// A single exportable calendar skips the selector.
// With more than one exportable calendar, let the user choose
// which to include; a single one exports straight away.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
@@ -185,8 +198,9 @@ fun BackupScreen(
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
// Nothing to back up (no writable local calendar), but events can
// still be restored into a writable calendar — offer restore on its
// own so it isn't hidden behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
@@ -225,8 +239,11 @@ private fun ExportCalendarPicker(
onExport: (Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// Deliberately not keyed on [calendars]: that list is observer-driven, so
// keying it would reset the user's de-selections on every provider re-emit.
// Seed once with everything selected and hold it across recomposition and
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
// it would silently reset the user's de-selections whenever the provider
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
// — the data layer intersects the chosen set with the eligible calendars.
var selected by rememberSaveable(
stateSaver = listSaver(
save = { it.toList() },
@@ -331,7 +348,7 @@ private fun BackupIntervalDialog(
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// Pick the largest unit the current value divides into.
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
val units = stringArrayResource(R.array.backup_interval_units).toList()
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)

View File

@@ -23,8 +23,10 @@ import javax.inject.Inject
/**
* The one-time notice that Calendula now follows the device's per-calendar
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
* switch those calendars back on — that would un-hide them everywhere else too.
* visibility (#75). Armed by `CalendarVisibilityReconciler` on the first launch
* that finds a calendar switched off outside the app — those used to show their
* events here and no longer do, and the app deliberately does not switch them
* back on, because that would un-hide them in every other calendar app too.
*/
@HiltViewModel
class CalendarVisibilityNoticeViewModel @Inject constructor(
@@ -33,9 +35,12 @@ class CalendarVisibilityNoticeViewModel @Inject constructor(
) : ViewModel() {
/**
* Reconcile whenever the app comes up with the calendar permission held,
* rather than off one grant route: a permission granted on Android's
* app-settings screen never reaches the permission screen's callback.
* Reconcile whenever the app comes up with the calendar permission held.
* The launch itself is covered by `CalendulaApp`, but a permission granted
* on Android's app-settings screen comes back through `RootScreen`'s
* ON_RESUME and never touches the permission screen's callback — so the
* trigger hangs off "we are showing the app", not off one grant route.
* Settled runs cost two DataStore reads and stop there.
*/
fun reconcile() {
viewModelScope.launch { reconciler.run() }

View File

@@ -107,11 +107,14 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete, and synced calendars
* read-only with a per-account "manage in the source app" deep-link.
* calendars with create / rename / recolor / delete (via a full-screen editor),
* and lists synced calendars read-only with a per-account "manage in the source
* app" deep-link — the app never touches a synced calendar's server.
*
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
* screen only points at it. [onBack] pops the destination.
* screen only points at it, because the two are looked for separately: "which
* calendars do I have" versus "keep a copy of them". A full-screen destination;
* [onBack] pops it.
*/
@Composable
fun CalendarsScreen(
@@ -249,7 +252,9 @@ private fun CalendarsList(
}
}
// Pointer to the Backup entry (#69), so it stays findable from here.
// Backup lives in its own Settings entry now (#69) — this row is the
// pointer, so someone looking at their local calendars still finds the
// way to keep a copy of them.
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_section_backup),
@@ -309,8 +314,8 @@ private fun CalendarsList(
onSetAccountVisible(switchable.map { it.id }, enabled)
},
) {
// Actionable calendars first; non-syncing ones at the
// bottom, dimmed and switchless.
// Calendars you can act on first; the ones this device isn't
// syncing sit at the bottom, dimmed and switchless.
val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
@@ -387,8 +392,11 @@ private fun CalendarEditor(
},
actions = {
if (!isNew) {
// Disabled rather than hidden while the special-dates
// sync owns this calendar; the card below says why.
// Kept in place while the special-dates sync owns this
// calendar, rather than hidden: the button is where you
// expect it, disabled, with the card below saying why —
// and it comes back to life the moment the feature is
// off, when the delete would actually stick.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
@@ -515,7 +523,9 @@ private fun CalendarEditor(
/**
* The row's supporting line: the states that make this calendar behave unlike a
* plain writable one (#76), then its own description.
* plain writable one (#76), then its own description. Text rather than badges —
* a row can carry several of these at once next to a switch, which is exactly
* what M3 supporting text composes and a row of static chips doesn't.
*/
@Composable
private fun calendarRowSummary(calendar: CalendarSource): String? {
@@ -533,9 +543,12 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
}
/**
* The per-row on/off control, writing the system's device-local
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description.
* The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
* = the calendar is shown, unchecked = it drops out of every surface (events,
* filters, pickers) and the provider stops scheduling its reminders. The flag is
* device-local — nothing is deleted and nothing is synced anywhere. Carries its
* own content description so the toggle is self-describing to screen readers
* even on a dimmed row.
*/
@Composable
private fun EnableSwitch(

View File

@@ -73,10 +73,18 @@ class CalendarsViewModel @Inject constructor(
)
/**
* Managed special-dates calendars whose deletion would not stick: while the
* feature is on, `SpecialDatesSyncEngine.reconcileCalendars` undoes it on
* the next pass. Read off each calendar's durable [CalendarSource.isManaged]
* marker rather than the stored ids, which lag a sync pass behind.
* Managed special-dates calendars whose deletion would not stick. While the
* feature is on, the sync owns every mirror: it recreates a missing one for
* an enabled type on the next pass and deletes the leftover of a disabled
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
* delete would appear to work and then undo itself. Turning special dates
* off empties this set, and deleting a leftover mirror is a real delete from
* then on.
*
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
* ids, which are only rewritten on the next sync pass — a preferences loss
* would otherwise unlock a live mirror until then.
*/
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
calendars,
@@ -139,18 +147,27 @@ class CalendarsViewModel @Inject constructor(
}
/**
* Switch a calendar on or off the app's one visibility model, writing the
* system's `Calendars.VISIBLE`. A reminder that came due while the calendar
* was off stays gone when it is switched back on: the watermark has already
* moved past it (#75).
* Switch a calendar on or off. This is the app's one visibility model: it
* writes the system's `Calendars.VISIBLE`, so the calendar disappears from
* every surface *and* the provider stops (or resumes) scheduling its
* reminders. Nothing is patched by hand — the provider notifies and the
* observer re-queries.
*
* A reminder that came due while the calendar was off stays gone when it is
* switched back on. Delivery plans from `Instances` and `Reminders` on every
* scan (#75), but the watermark has already moved past that moment, so no
* scan returns it again — and on a read-only install the switch never
* reaches the provider at all, so nothing even triggers one. Deliberate: the
* reminder was silenced on purpose, and the event itself is back in view.
*/
fun setCalendarVisible(id: Long, visible: Boolean) = write {
repository.setCalendarsVisible(listOf(id), visible)
}
/**
* Switch every calendar of one account on or off. Each row is written on its
* own, in one coroutine so the writes can't race.
* Switch every calendar of one account on or off — the "toggle all"
* affordance on an account header. Each row is written on its own, in one
* coroutine so the writes can't race each other.
*/
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
repository.setCalendarsVisible(ids, visible)

View File

@@ -9,8 +9,13 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
/**
* One account's calendars, as every surface that lists calendars by account
* shows them. An account is identified by name **and** type (#77): a Google and
* a DAVx5 account can share an address and still be two separate accounts.
* shows them.
*
* An account is identified by name **and** type (#77). A Google account and a
* DAVx5 account can carry the same address and still be two separate accounts,
* from separate apps: merging them mixed their calendars into one group whose
* header — source logo, "manage in app", toggle-all, collapsed state — was
* derived from whichever calendar happened to sort first.
*/
data class CalendarAccountGroup(
/** Stable identity: what makes two calendars belong to the same account. */
@@ -64,8 +69,9 @@ fun accountGroupTitle(group: CalendarAccountGroup): String =
}
/**
* The human name of the app backing [accountType], falling back to the raw
* account type when no installed app resolves for it.
* The human name of the app backing [accountType] — the same app whose icon
* [SourceLogo] draws. Falls back to the raw account type, which is at least
* unique, when no installed app resolves for it.
*/
@Composable
fun sourceAppName(accountType: String): String {

View File

@@ -45,9 +45,11 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
* each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome.
*
* The list holds event *targets* only, so a switched-off, read-only or managed
* calendar is absent (#76). [onManageCalendars], when given, adds the footer row
* naming the possible reasons and opening the calendar manager.
* The list holds event *targets* only, so a calendar that is switched off,
* read-only or managed is silently absent — which reads as a missing calendar
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
* footer row that names the possible reasons and opens the calendar manager,
* where each row then says which one applies.
*/
@Composable
fun ColumnScope.CalendarPickerGroups(

View File

@@ -59,9 +59,10 @@ import kotlin.time.Clock
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
* back.
*
* [leadTimeSummary] adds a second line to each lead-time row, for the all-day
* pickers: the hour comes from the separate "show all-day reminders at" setting,
* so the lead time alone doesn't say when anything happens.
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
* all-day pickers, where the lead time alone doesn't say when anything happens:
* the hour comes from the separate "show all-day reminders at" setting, so
* "1 day before" is only half the answer until the row spells out the time.
*/
@Composable
fun ReminderDefaultPicker(
@@ -232,8 +233,10 @@ internal fun PickerDescription(text: String) {
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*
* Every option carries the dates it resolves to today as its summary, computed
* through the same [dayCount] the agenda windows by (hence [weekStart]) — only
* the concrete span tells "This week" and "Next 7 days" apart.
* through the same [dayCount] the agenda windows by (hence [weekStart], which
* "This week" depends on). Without it the two groups are near-indistinguishable
* — "This week" and "Next 7 days" name different windows in the same words, and
* only the concrete span tells them apart.
*/
@Composable
fun AgendaRangePicker(
@@ -257,11 +260,11 @@ fun AgendaRangePicker(
val locale = currentLocale()
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
// The agenda's own end-day arithmetic (anchor + dayCount - 1), so the dates
// match what it will show.
// The window each option would open right now — the agenda's own end-day
// arithmetic (anchor + dayCount - 1), so the dates match what it will show.
val windowSummary: (AgendaRange) -> String = { range ->
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
agendaRangeWindowSummary(range, today, end, locale)
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
@@ -303,8 +306,8 @@ fun AgendaRangePicker(
} else {
stringResource(R.string.agenda_range_custom)
},
// An unset custom window has no day count yet, so the row stays
// single-line until it does.
// Only a chosen custom window has dates to show; an unset one has no
// day count yet, so the row stays single-line until it does.
summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,

View File

@@ -105,19 +105,29 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
/**
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
* the recurrence picker, where [recurrenceText]'s phrase says what the rule is
* and this says what it does. Expands via [upcomingOccurrences] and inherits its
* limits. A rule that yields nothing says so rather than showing an empty list.
* the recurrence picker.
*
* The phrase [recurrenceText] renders says what the rule *is*; this says what
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
* rule with weekday picks gets wrong in people's heads. It expands the rule
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
* its limits: only the shapes the picker itself can build.
*
* A rule that yields nothing at all (an end date before the start) says so
* rather than showing an empty list, since that is a mistake worth catching
* before saving.
*/
@Composable
fun nextOccurrencesText(
rule: SimpleRecurrence,
// Spelled out: in this file, the bare LocalDate is java.time's.
start: kotlinx.datetime.LocalDate,
locale: Locale,
): String {
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
// Years only once they carry information (see [spansMultipleYears]).
// Years only once they carry information — a yearly rule is otherwise the
// same date repeated, and a monthly one crossing New Year hides that it did.
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
val formatter = localizedDateFormatter(locale, pattern)
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }

View File

@@ -515,8 +515,8 @@ private fun EventEditContent(
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
val locked = state.isManaged
// Read in the form's own window, not the picker's: the focused field lives
// here, so this is the controller that can put its keyboard away.
// Read in the form's own window, not the picker's: the field holding focus
// lives here, so this is the controller that can put its keyboard away.
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
var picker by remember { mutableStateOf<PickerTarget?>(null) }
@@ -1124,8 +1124,10 @@ private fun EventEditContent(
null -> Unit
}
// A full-screen picker is a change of place, so the keyboard shouldn't
// follow it. The field keeps its text; only focus and the IME go.
// A full-screen picker over the form is a change of place, so the form's
// keyboard has no business following it there — least of all onto the
// calendar manager, which the picker can hand off to. The form's own field
// keeps its text; only focus and the IME go.
LaunchedEffect(showCalendarPicker) {
if (showCalendarPicker) {
focusManager.clearFocus(force = true)
@@ -1141,9 +1143,11 @@ private fun EventEditContent(
viewModel.setCalendar(it)
showCalendarPicker = false
},
// Close the picker first: it is a Compose Dialog in its own window,
// always above the activity's content, so the manager would
// otherwise open behind it and the tap would look dead.
// Close the picker on the way out. It is a Compose Dialog its own
// window, always above the activity's content so the manager would
// otherwise open behind it and the tap would look dead. The form
// stays standing underneath, its calendar row one tap from a picker
// that re-queries on open.
onManageCalendars = onManageCalendars?.let { openManager ->
{
showCalendarPicker = false
@@ -1340,7 +1344,10 @@ private enum class RecurrenceEndMode { Never, Until, Count }
*
* Both steps show the rule as *dates* as well as words: every preset row
* carries the next few occurrences it would produce from [startDate], and the
* custom step repeats that under its live read-out.
* custom step repeats that under its live read-out. It is where a rule most
* easily means something other than it sounds like — "monthly" on the 31st
* skips February, an every-other-week rule lands a fortnight from the start's
* own week — and only the dates say so.
*/
@Composable
private fun RecurrencePickerDialog(
@@ -1402,8 +1409,9 @@ private fun RecurrencePickerDialog(
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
}
// Kept as the rule object, not just its RRULE text: the read-out and the
// date list must describe the one thing OK would save.
// Kept as the rule object, not just its RRULE text: the read-out renders the
// string, the date list expands the rule, and both must describe the one
// thing OK would save.
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
SimpleRecurrence(
freq = freq,
@@ -1497,8 +1505,9 @@ private fun RecurrencePickerDialog(
.padding(horizontal = 16.dp),
)
// Rendered even while incomplete (as an empty line), so the
// controls below don't shift as it comes and goes.
// The same rule as dates. Rendered even while the form is
// incomplete (as an empty line) so the controls below don't
// shift as it comes and goes.
Text(
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
@@ -2025,8 +2034,10 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
* Visibility selector: one card per level, each with its own icon; the
* current level is highlighted. Tap picks and closes.
*
* Every level carries a line saying who this affects — the labels alone don't
* say that visibility is about what others on a shared calendar see.
* Every level carries a line saying who this affects, because the four words
* alone don't: visibility is about what *other people on a shared calendar*
* see, which is exactly the part the label leaves out — and "Confidential" has
* no meaning at all until you know the server decides what it does with it.
*/
@Composable
private fun VisibilityPickerDialog(

View File

@@ -233,9 +233,11 @@ class EventEditViewModel @Inject constructor(
// off the calendar's durable marker, not a stored id, so it holds after a
// backup restore too.
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
// The event's own calendar is added back whenever it isn't a target
// (managed, or switched off), so the row keeps naming it and saving can
// leave the event where it is. A read-only one is still no target.
// The picker offers writable calendars only; the event's own calendar is
// added back whenever it isn't among them — a managed special-dates one,
// or one switched off on this device — so the row keeps naming it instead
// of reading as the "no calendar" error, and saving can leave the event
// where it is. A calendar the app may not write to is still no target.
val ownCalendar = resolvedCalendar?.takeIf { own ->
own.canModifyContents && external.writable.none { it.id == own.id }
}

View File

@@ -32,8 +32,9 @@ class FilterViewModel @Inject constructor(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
// Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars.
// Calendars switched off in Settings → Calendars are off device-wide
// and don't belong in the drawer's hide/show list (you can't hide
// what is already off). They live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)

View File

@@ -175,8 +175,10 @@ private fun ManyContent(
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
// No calendar to import into; carries the same way out the picker's footer
// offers below (#76).
// No calendar to import into — tell the user honestly, and carry the same
// way out the picker's footer offers below. This is the state that footer
// exists for: every writable calendar being switched off, read-only or
// contact-filled is exactly what empties this list (#76).
if (state.calendars.isEmpty()) {
CenteredMessage(
message = stringResource(R.string.import_no_calendar),

View File

@@ -86,7 +86,9 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings,
)
else -> {
// The same targets the event form offers ([isEventTarget]).
// The same targets the event form offers ([isEventTarget]):
// an import is a bulk create, so a calendar that can't hold
// one event can't hold thirty.
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,

View File

@@ -365,8 +365,10 @@ fun MonthScreen(
todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday,
onCreate = {
// Split has a selected day; the other styles anchor on
// today when its month is shown, else the 1st.
// Split has a selected day and lists it below the grid, so
// that is the day the new event belongs to. The other
// styles have no selection: anchor on today when its month
// is shown, else the 1st.
onCreateEvent(
when {
viewStyle == MonthViewStyle.Split -> selectedDate
@@ -2073,9 +2075,10 @@ private fun MonthBar(
}
/**
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest. A
* dot dims once every event sharing its colour has ended, the "+N" once the
* whole overflow has (#79).
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest.
*
* A dot stands for every hidden event sharing its colour, so it dims only once
* all of them have ended; the "+N" dims once the whole overflow has (#79).
*/
@Composable
private fun OverflowDots(

View File

@@ -68,9 +68,12 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
}
/**
* The events on [day] that [laneEvents] had no lane left for — its exact
* complement, in the same bars-then-pills order. Returned as events rather than
* a count because dimming is a per-event question (#79).
* The events on [day] that [laneEvents] had no lane left for — the exact
* complement of what it seats, in the same bars-then-pills order. Together the
* two partition the day, so their sizes add up to [countByDay].
*
* The "+N" marker needs the events themselves, not just how many there are:
* dimming a completed event is a per-event question (#79).
*/
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }

View File

@@ -13,8 +13,8 @@ class PermissionViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
// The visibility reconcile a grant owes (#75) hangs off RootScreen, which
// also catches grants made outside the app.
// The visibility reconcile a grant owes (#75) hangs off RootScreen showing
// the app instead: it has to cover the grants made outside it too.
fun onGranted() {
_state.value = PermissionUiState.Granted
}

View File

@@ -37,7 +37,8 @@ class ReminderOnboardingViewModel @Inject constructor(
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
// Nothing else re-arms the scan: turning reminders off cancels the
// alarm (#75).
// alarm, so a later "on" would sit without one until a provider
// change or the daily worker happened to run (#75).
scanner.scan()
}
}

View File

@@ -223,9 +223,10 @@ private fun searchSummary(event: EventInstance): String {
val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
}
// From the shared span rule, not [start]: an all-day event sits at UTC
// midnight and would name the day before west of UTC (#82). The clock time
// below stays device-zone — it is only rendered for timed events.
// The date comes from the shared span rule, not from [start]: an all-day
// event sits at UTC midnight, so reading its date in the device zone names
// the day before west of UTC (#82). The clock time below stays in the device
// zone — it is only ever rendered for timed events.
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())

View File

@@ -84,6 +84,7 @@ internal fun AppearanceScreen(
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
// A picked file that didn't parse as a font: tell the user and keep the old choice.
val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
LaunchedEffect(Unit) {
@@ -106,6 +107,8 @@ internal fun AppearanceScreen(
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
// Says what it does when on; below Android 12 that is replaced by
// the reason the switch is dead.
summary = if (state.dynamicColorAvailable) {
stringResource(R.string.settings_dynamic_color_summary)
} else {
@@ -140,7 +143,9 @@ internal fun AppearanceScreen(
Spacer(Modifier.height(16.dp))
// Fonts — the two Material typeface roles (#19).
// Fonts — the two Material typeface roles, each independently choosable
// (issue #19). Headings = brand (display/headline); body = plain
// (title/body/label). Both default to the system typeface.
GroupedRow(
title = stringResource(R.string.settings_font_headings),
summary = fontLabel(fonts.brand),
@@ -156,8 +161,10 @@ internal fun AppearanceScreen(
Spacer(Modifier.height(16.dp))
// App name — the launcher label (#44); its own group, being a
// launcher concern rather than app styling.
// App name — chooses the launcher label between "Calendula" and "Calendar"
// (issue #44). Own group: it's a launcher/system concern, not app styling.
// A sub-page chooser (not a switch), matching the app's other "choose one"
// settings and leaving room for more names later.
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
@@ -172,8 +179,10 @@ internal fun AppearanceScreen(
onDismiss = { showAppName = false },
predictiveBack = true,
) {
// Both names shown as launcher-mark previews; tapping applies at
// once and the picker stays open so the change is visible.
// Show both names as launcher-mark previews so the user sees what
// they'd switch to, not just the current state. Tapping applies
// immediately and highlights — the picker stays open so the change is
// visible; back exits.
Text(
text = stringResource(R.string.settings_app_name_summary),
style = MaterialTheme.typography.bodyMedium,
@@ -202,8 +211,10 @@ internal fun AppearanceScreen(
}
}
if (showTheme) {
// No thumbnails: picking a theme repaints the app itself. Only which
// way "Follow the system" currently falls needs spelling out.
// No preview here on purpose: picking a theme repaints the app itself,
// which is a better demonstration than any thumbnail. What the list
// *can't* show is which way "Follow the system" currently falls, so that
// one option says so.
val systemDark = isSystemInDarkTheme()
OptionPicker(
title = stringResource(R.string.settings_theme),
@@ -296,8 +307,19 @@ private val FONT_PICKER_MIME_TYPES = arrayOf(
/**
* Full-screen font chooser for one [FontRole]: the system default, each bundled
* font, and "Choose file…" for a .ttf/.otf. A specimen of the current choice
* sits above the rows, so the picker stays open on selection and re-renders it.
* font (previewed in its own face), and "Choose file…" which opens the system
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
* a file is validated and imported by the caller, switching to the custom font
* on success.
*
* Above the rows sits a specimen of the *current* choice, set in the type role
* this picker governs — headline for [FontRole.BRAND], body for
* [FontRole.PLAIN]. The per-row "Ag" says what a face looks like; only a full
* line at the real size says whether it works for the role, and it is the one
* thing that makes the two font settings distinguishable from each other.
*
* Being a preview picker, it stays open on selection (see `FullScreenPicker`):
* the specimen re-renders instead, which is the whole point of the screen.
*/
@Composable
private fun FontPicker(
@@ -313,17 +335,22 @@ private fun FontPicker(
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
// Stay open on a successful import too: the specimen switches to the
// imported face, which is the only look the user gets before committing.
if (uri != null) onImport(uri)
}
// System default + the bundled fonts + the "Choose file…" row.
val rowCount = BundledFont.entries.size + 2
val isCustom = selected == FONT_CUSTOM_TOKEN
// Resolving the custom face stats the disk, so memoise it; [stamp] is bumped
// on re-import to refresh a replaced file.
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
// refreshes the preview while plain recompositions reuse the cached family.
val customPreview = remember(role, isCustom, stamp) {
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
}
// The face the specimen is set in: the same resolution the theme performs,
// so what is shown here is what the app will use.
val selectedFamily = remember(role, selected, stamp) {
resolveFontFamily(selected, role, context)
}
@@ -335,6 +362,8 @@ private fun FontPicker(
preview = FontFamily.Default,
selected = selected == FONT_SYSTEM_TOKEN,
position = positionOf(0, rowCount),
// Applies straight away and stays open — the specimen above is the
// answer to "what does this one look like".
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
)
BundledFont.entries.forEachIndexed { index, font ->
@@ -352,6 +381,7 @@ private fun FontPicker(
} else {
stringResource(R.string.settings_font_choose_file)
},
// A loaded font previews in its own face; otherwise show an upload cue.
preview = customPreview,
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
selected = isCustom,
@@ -362,8 +392,11 @@ private fun FontPicker(
}
/**
* A specimen of [family] in the type role [role] governs, using the app's own
* styles with only the family swapped. A null [family] is the system typeface.
* A specimen of [family] set in the type role [role] governs: a headline for
* the brand role, a paragraph for the plain one — the same styles the app draws
* with, only the family swapped, so nothing here can flatter a face the app
* won't reproduce. A null [family] is the system typeface, i.e. the Material
* default the styles already carry.
*/
@Composable
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
@@ -379,8 +412,8 @@ private fun FontSpecimen(role: FontRole, family: FontFamily?) {
),
style = style.copy(fontFamily = family ?: style.fontFamily),
color = MaterialTheme.colorScheme.onSurface,
// Fixed height, so switching between a wide and a narrow face doesn't
// shuffle the option list under it.
// Two lines up front so switching between a wide and a narrow face
// doesn't shuffle the option list up and down under it.
minLines = 2,
modifier = Modifier
.fillMaxWidth()
@@ -389,8 +422,9 @@ private fun FontSpecimen(role: FontRole, family: FontFamily?) {
}
/**
* One row in the [FontPicker]: the font's name, an "Ag" sample in its own
* [preview] face (or an [leadingIcon] cue), and a check when selected.
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
* to preview), and a check when it's the current selection.
*/
@Composable
private fun FontOptionRow(
@@ -426,8 +460,11 @@ private fun FontOptionRow(
}
/**
* One selectable launcher-name preview in the App name picker (#44): the app's
* launcher mark over the name, framed as a card.
* One selectable launcher-name preview in the App name picker (issue #44): the
* app's launcher mark over the name, framed as a card. The active one carries a
* primary border, a tinted container and a check; tapping selects it. The mark
* is the same for both — only the label changes — so the card previews exactly
* what the home screen will read.
*/
@Composable
private fun AppNameOptionCard(
@@ -457,8 +494,8 @@ private fun AppNameOptionCard(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// The adaptive mark rebuilt as a squircle, as in the onboarding
// BrandHero, so it renders identically everywhere.
// The adaptive launcher mark, reconstructed as a squircle (as in the
// onboarding BrandHero) so it renders identically everywhere.
Box(
modifier = Modifier
.size(64.dp)
@@ -478,6 +515,7 @@ private fun AppNameOptionCard(
textAlign = TextAlign.Center,
maxLines = 1,
)
// Selection indicator: a filled check when active, an empty ring otherwise.
Box(
modifier = Modifier
.size(24.dp)

View File

@@ -40,7 +40,8 @@ internal fun EventFormScreen(
GroupedRow(
title = stringResource(eventFormFieldLabel(field)),
position = positionOf(index, fields.size),
// The same icon the field carries in the new-event form.
// Same icon the field carries in the new-event form, so a toggle
// is easy to match to the field it controls.
leading = {
Icon(
imageVector = eventFormFieldIcon(field),
@@ -58,7 +59,9 @@ internal fun EventFormScreen(
)
}
// Auto-focus the title on a new event (#10), on by default.
// Auto-focus the title on a new event (issue #10) on by default, since
// most events get a title; raising the keyboard saves a tap. Off lets you
// set the time/calendar first without the keyboard in the way.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_autofocus_title),
@@ -81,7 +84,8 @@ internal fun EventFormScreen(
)
// Per-event colour on calendars that publish no colour set (some
// CalDAV); off by default, since it may not survive their next sync.
// CalDAV) off by default, with the honest caveat that the colour may
// not survive their next sync. Local and palette calendars ignore it.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_color_unsupported),

View File

@@ -124,8 +124,11 @@ internal fun NotificationsScreen(
onClick = { showAllDayReminderTime = true },
)
// Reliability is a soft, optional battery-optimisation exemption via
// a system-settings deep-link, shown as live status.
// Delivery reliability + snooze: both are global reminder-delivery
// settings, so they sit with the defaults above rather than below the
// long per-calendar list. Reliability is a soft, optional battery-
// optimisation exemption (system-settings deep-link, no special
// permission); shown as live status, reversible at any time.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
@@ -144,6 +147,7 @@ internal fun NotificationsScreen(
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
@@ -151,9 +155,10 @@ internal fun NotificationsScreen(
onClick = { showSnooze = true },
)
// Per-calendar overrides, folded behind one header. Each writable
// calendar may keep, drop or replace the global default, separately for
// timed and all-day events.
// Per-calendar overrides: the whole section folds behind one header to
// keep the screen tidy. Expanded, each writable calendar gets its own
// expandable card that may keep, drop, or replace the global default —
// separately for timed and all-day events.
if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
@@ -177,8 +182,8 @@ internal fun NotificationsScreen(
Column {
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// Special-dates calendars own their reminders in their
// own section — link there instead.
// A contact special-dates calendar owns its reminders in
// its own section — link there instead of an override.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName,
@@ -197,6 +202,8 @@ internal fun NotificationsScreen(
return@forEach
}
val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list
// of three (the card + the timed and all-day rows).
GroupedRow(
title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone,
@@ -359,7 +366,8 @@ private fun snoozeDurationLabel(minutes: Int): String =
/**
* Whether Calendula is exempt from battery optimisation, re-read on every
* `ON_RESUME` so a change made in system settings shows up at once.
* `ON_RESUME` so the row reflects a change the user just made in system
* settings without needing to leave and re-enter the screen.
*/
@Composable
private fun rememberBatteryOptimizationExempt(): Boolean {
@@ -384,8 +392,10 @@ private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
}
/**
* Open the direct `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog, falling back to
* the optimisation list if the OS refuses it.
* Take the user straight to Calendula's exemption: the direct
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
* battery optimisation?") rather than the full app list they'd have to scroll.
* Falls back to the optimisation list if the OS refuses the direct intent.
*/
private fun openBatteryOptimizationSettings(context: Context) {
val direct = Intent(

View File

@@ -4,7 +4,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -18,6 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -41,9 +41,19 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* The "past events" chooser for the Agenda (#69 follow-up). A stand-in agenda
* day sits above the options and re-renders as you pick one, using the Agenda's
* own rows and filtering. A preview picker, so it stays open on selection.
* The "past events" chooser for the Agenda (#69 follow-up).
*
* "Show / dim / hide" is the case where the word cannot carry the choice —
* *how far* does dimmed fade, and what is left behind when finished events are
* hidden? So a stand-in agenda day sits above the options and re-renders as you
* pick one, using the very rows the Agenda draws: dimming is
* [AgendaEventRow]'s own `dimmed` flag, and hiding drops the row exactly as
* [de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen] filters it — so the
* preview cannot claim an effect the screen doesn't have.
*
* Like the Month style and App name pickers, selecting applies immediately and
* leaves the picker open: closing would hide the thing the screen is for. Back
* exits.
*/
@Composable
internal fun PastEventsPicker(
@@ -92,6 +102,7 @@ internal fun PastEventsPicker(
} else {
null
},
// Applies straight away; the preview above is the confirmation.
onClick = { onSelect(mode) },
)
}
@@ -100,8 +111,9 @@ internal fun PastEventsPicker(
/**
* A stand-in agenda day in a given [mode]: three events on today, the first two
* finished. Times are fixed rather than clock-derived, so it reads the same at
* any hour.
* already finished. The times are fixed rather than derived from the clock, so
* the preview reads the same at any hour — what varies is only what [mode] does
* to the two finished rows.
*/
@Composable
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
@@ -113,17 +125,24 @@ private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modif
stringResource(R.string.settings_past_events_sample_evening),
)
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
// The same three-way split AgendaContent makes.
// Hiding drops the finished rows outright; showing and dimming keep them,
// and only dimming fades them — the same three-way split AgendaContent makes.
val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample
Column(
modifier = modifier
.fillMaxWidth()
.clipToBounds()
// A picture, not a control: take the taps the fake rows would
// otherwise handle, and keep it out of the reading order. Taps only
// consuming drags would stop the picker scrolling from here.
.pointerInput(Unit) { detectTapGestures { } }
// A preview is a picture, not a control: swallow touches before the
// rows' own clickables see them, and keep it out of the reading order —
// the options below already say what is selected.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
}
.clearAndSetSemantics { },
) {
AgendaDayHeader(date = today, today = today, onOpenDay = {})
@@ -144,8 +163,11 @@ private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modif
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
/**
* Today's stand-in events, [titles] in order: two finished and one to come.
* Colours are raw ARGB, as the provider hands them out.
* Today's stand-in events, [titles] in order: two finished (morning, midday) and
* one still to come.
*
* Colours are raw ARGB on purpose — that is what the provider hands out for an
* event, so a theme token here would misrepresent what the agenda renders.
*/
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
listOf(

View File

@@ -31,13 +31,23 @@ import de.jeanlucmakiola.floret.locale.currentLocale
*/
/**
* Accent for a leading icon chip. The chips are a scanning aid, so no two rows
* in one group share an accent; [Neutral] is a step back for reference rows
* rather than a fourth colour to rotate through.
* Token-based accent for a leading icon chip (container / on-container pair).
* Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
* to theme, dark mode and dynamic colour.
*
* The chips are a scanning aid, so the rule is **no two rows in one group share
* an accent** — a group in a single colour carries no more information than no
* colour at all. [Neutral] is not a fourth colour to rotate through but a
* deliberate step back, for rows that are reference or last-resort rather than
* somewhere you routinely go.
*/
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
/** Leading circular icon chip, coloured by an M3 container/on-container pair. */
/**
* Leading circular icon chip. Colours come from the M3 scheme via a container /
* on-container token pair, so each accent stays correctly paired across theme,
* dark mode and dynamic colour.
*/
@Composable
internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
val scheme = MaterialTheme.colorScheme
@@ -65,7 +75,9 @@ internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
/**
* A small primary-coloured group label, matching the Calendars settings screen.
* Inset to [GroupedListInset] so it shares the cards' left margin.
*
* Starts on [GroupedListInset] — the cards' own edge — so a header, the hint
* under it and the group it names all share one left margin.
*/
@Composable
internal fun SectionHeader(text: String) {
@@ -105,18 +117,34 @@ internal fun reminderChoiceLabel(minutes: List<Int>): String {
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
}
/** Day-scale lead times for all-day defaults and contact special dates. */
/**
* Lead times offered for the all-day default — day-scale, since a "minutes
* before midnight" reminder on an all-day event is rarely what's wanted. Shared
* with the contact special-dates calendars, whose events are all all-day.
*/
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
/** A minute-of-day in the app's own 12/24-hour convention ([LocalUse24HourFormat]). */
/**
* A minute-of-day ("09:00") in the app's *own* 12/24-hour convention — the same
* [LocalUse24HourFormat] the agenda and event rows read, so a time shown in
* settings can't disagree with the times shown everywhere else.
*/
@Composable
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale())
/**
* Second line for an all-day lead-time row: the clock time it fires at. The
* offset only decides which day; the hour comes from the global all-day setting
* (see [de.jeanlucmakiola.calendula.domain.reminders.planReminders]).
* Second line for an all-day lead-time row: the clock time it fires at.
*
* An all-day occurrence has no time of its own, so a reminder's stored offset
* only decides *which day* it belongs to — the hour always comes from the
* global "show all-day reminders at" setting (see
* [de.jeanlucmakiola.calendula.domain.reminders.planReminders]). Naming it on
* every row is what makes "At time of event" readable on an all-day default at
* all: it means that day at 09:00, not midnight.
*
* Shared by the notification defaults, the per-calendar overrides and the
* contact special-dates calendars — all three read the same one setting.
*/
@Composable
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {

View File

@@ -70,10 +70,15 @@ import de.jeanlucmakiola.floret.locale.AppLanguage
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
/**
* Settings hub: three labelled groups of category rows, each opening a
* sub-screen from its own `*Settings.kt` file (#69). Calendars and Backup open
* screens hoisted in `CalendarHost`; Language opens a full-screen picker.
* [onBack] pops the destination.
* Settings (M4), restructured in v2.3 into a category hub with sub-screens and
* re-sorted in v2.17 (#69) so every setting sits where it is looked for.
*
* The hub reads as three labelled groups — what the app looks like and how it
* behaves, what it does with your data, and the app itself — and each sub-screen
* lives in its own `*Settings.kt` file. Calendars and Backup open screens
* hoisted in `CalendarHost` (they are driven by the calendar list, not by
* preferences); Language opens a full-screen picker; About is a card at the top.
* A full-screen destination; [onBack] pops it.
*/
@Composable
fun SettingsScreen(
@@ -161,14 +166,18 @@ private fun SettingsHub(
onOpenBackup: () -> Unit,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
// Card and support row are one grouped block, so the call to action
// continues the container instead of sitting inside it as a button.
// The card and the support row are one grouped block: the call to action
// is a row continuing the container rather than a tonal button sitting
// inside the card.
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
SupportRow()
Spacer(Modifier.height(16.dp))
// Each group cycles its chip accents so no two rows in it look alike
// (see [ChipAccent]).
// Three labelled groups instead of one long undifferentiated list (#69):
// how the app presents itself, what it does with your data, and the app
// as an installed thing. Each group cycles its chip accents so no two
// rows in it look alike (see [ChipAccent]) — a uniformly coloured group
// is as unscannable as an uncoloured one.
SectionHeader(stringResource(R.string.settings_group_look))
GroupedRow(
title = stringResource(R.string.settings_section_appearance),
@@ -215,8 +224,9 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.SpecialDates) },
)
// Export/import gets its own top-level entry (#69); the calendar
// manager keeps a pointer row to here.
// Export/import used to hide inside the calendar manager, where nobody
// looked for it (#69). It is a data-safety feature, so it gets its own
// top-level entry; the manager keeps a pointer row to here.
GroupedRow(
title = stringResource(R.string.settings_section_backup),
summary = stringResource(R.string.settings_backup_subtitle),
@@ -237,8 +247,10 @@ private fun SettingsHub(
LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom)
// Source, licence and privacy sit at the bottom as reference material
// rather than inside the About card.
// Source, licence and privacy sit at the very bottom rather than in the
// About card: they are reference material you look up once, not settings
// you come here to change, and the card at the top now leads with what
// the app *is* plus the one link worth offering (support).
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_group_about))
AboutLinkRow(
@@ -352,8 +364,11 @@ private fun languageLabel(tag: String?): String =
@Composable
private fun AboutCard() {
// Layout lives in floret-kit (components.AboutCard); Calendula supplies
// its own logo and author. Support is the [SupportRow] joined below.
// The card layout lives in floret-kit (components.AboutCard); Calendula
// supplies its own logo and author. Source, licence and privacy moved to
// rows at the foot of the page, and support is now the [SupportRow] joined
// to the bottom of this card — so the card itself carries nothing but what
// the app is and who made it.
AboutCard(
logo = { AppLogo() },
appName = stringResource(R.string.app_name),
@@ -363,7 +378,11 @@ private fun AboutCard() {
)
}
/** "Support development", as the closing row of the About card's group. */
/**
* "Support development", as the closing row of the About card's group rather
* than a tonal button inside it. Same weight as any other row you can tap, with
* a primary chip to keep it the one accent in this block.
*/
@Composable
private fun SupportRow() {
val context = LocalContext.current
@@ -377,9 +396,10 @@ private fun SupportRow() {
}
/**
* One reference link at the foot of the hub (source, licence, privacy). The
* privacy policy has to stay reachable from inside the app, not only from the
* store listing.
* One reference link at the foot of the hub (source, licence, privacy): a plain
* grouped row that opens the URL in the browser. The privacy policy in
* particular has to stay reachable from inside the app, not only from the store
* listing, because Calendula touches calendar and contact data.
*/
@Composable
private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position: Position) {
@@ -413,9 +433,15 @@ private fun AppVersionText() {
}
/**
* The app icon as a rounded chip: the launcher mark over its background colour,
* oversized and clipped the way a launcher mask would. Sized just past the two
* text lines beside it; [MARK_OVERSCAN] holds the crop as it shrinks.
* The app icon as a rounded chip: the off-white launcher mark over its slate
* background colour, rendered oversized and clipped to fill the chip the way a
* launcher mask would.
*
* Sized just past the two text lines beside it, so the card's height is set by
* its content rather than by the mark towering over it. That also keeps it a
* step above the 40dp category chips on the rows below — still the block's
* anchor, no longer its outlier. The mark is a vector, so the size is purely a
* layout choice; [MARK_OVERSCAN] holds the launcher-mask crop as it shrinks.
*/
@Composable
private fun AppLogo() {
@@ -438,7 +464,8 @@ private fun AppLogo() {
private val LOGO_SIZE = 56.dp
/**
* How far the mark overruns its chip. An adaptive icon's foreground carries a
* wide safe margin, so at 1:1 it would sit small and lost; launchers crop it too.
* How far the mark overruns its chip. An adaptive icon's foreground is drawn
* with a wide safe margin, so at 1:1 the flower would sit small and lost inside
* the chip; the launcher crops the same way.
*/
private const val MARK_OVERSCAN = 1.5f

View File

@@ -80,8 +80,9 @@ class SettingsViewModel @Inject constructor(
/**
* Writable calendars that are switched on — the only ones that take a
* per-calendar reminder override, since a switched-off one plans no
* reminders at all (#75).
* per-calendar reminder override. A calendar switched off in Settings →
* Calendars is `VISIBLE = 0`, so the provider schedules no alarms for it and
* a default reminder configured there could never fire (#75).
*/
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
@@ -224,10 +225,17 @@ class SettingsViewModel @Inject constructor(
)
/**
* The launcher-label choice (#44), backed by the manifest aliases'
* component-enabled state rather than a preference. Seeded with the manifest
* default and corrected off-thread — `getComponentEnabledSetting` is a
* binder round-trip and this ViewModel is built on the main thread.
* The launcher-label choice (issue #44). Backed by the manifest aliases'
* component-enabled state rather than a stored preference, so it's read
* imperatively via [LauncherNameManager] and held here in its own flow — the
* main settings combine is already at its arity limit and this isn't a
* DataStore flow anyway.
*
* Seeded with the manifest default and corrected off-thread rather than read
* in the initializer: `getComponentEnabledSetting` is a binder round-trip to
* system_server, and this ViewModel is constructed on the main thread while
* Settings is opening. The row it feeds is well below the fold, so the
* one-frame correction is never visible.
*/
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
@@ -243,8 +251,10 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws: concurrent updateAll calls coalesce around a
// stale read and can leave the widget on an intermediate value.
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
// updateAll calls at once — concurrent calls coalesce around a stale read
// and can leave the widget on the intermediate value. Held sequentially,
// the last call reads the final committed pref and renders it.
private val widgetRefreshMutex = Mutex()
private data class ReminderDefaults(
@@ -397,7 +407,9 @@ class SettingsViewModel @Inject constructor(
fun setSoftenColors(enabled: Boolean) {
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener.
// Both widgets paint event colours through the same softener, so a
// change has to redraw them too (they read the flag in their data
// preamble, like is24Hour).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
@@ -423,8 +435,9 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
if (ok) {
// Bump the stamp so replacing an already-active custom font
// still breaks equality and refreshes the resolved typeface.
// Bump the stamp so replacing an already-active custom font (same
// file, same "custom" token) still breaks font-settings equality
// and refreshes the resolved typeface.
prefs.bumpCustomFontStamp(role)
setFont(role, FONT_CUSTOM_TOKEN)
} else {
@@ -453,8 +466,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
prefs.setAgendaWidgetRange(range)
widgetRefreshMutex.withLock {
// Push into each instance's Glance state and recompose:
// updateAll alone won't re-run provideGlance's data preamble.
// Push the range into each instance's Glance state and recompose.
// The widget reads it via currentState, so this reflects reliably
// even when updateAll only recomposes a live session (it does not
// re-run provideGlance's data preamble).
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
@@ -469,9 +484,13 @@ class SettingsViewModel @Inject constructor(
}
/**
* Set the size the agenda widget draws its text at (#51), via the same
* Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
* sizes itself from the space it is given (#103).
* Set the size the agenda widget draws its text at (#51). Pushed into every
* instance's Glance state and recomposed — the same reliable-update path as
* [setAgendaWidgetRange], since `updateAll` alone won't re-run the data
* preamble a live session already ran.
*
* Agenda-only on purpose: the month widget sizes itself from the space it is
* given and always has, so it takes no size setting (#103).
*/
fun setWidgetSize(size: WidgetSize) {
viewModelScope.launch {
@@ -489,7 +508,9 @@ class SettingsViewModel @Inject constructor(
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// Same Glance-state path as setPastEventDisplay.
// The agenda widget reads this reactively, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
@@ -517,9 +538,10 @@ class SettingsViewModel @Inject constructor(
}
/**
* Switch the launcher label (#44). The card highlights at once, then settles
* on what the component state reports; the writes are binder round-trips and
* run off the main thread.
* Switch the launcher label between "Calendula" and "Calendar" (issue #44).
* The card highlights immediately, then settles on whatever the component
* state actually reports — the two `setComponentEnabledSetting` calls are
* binder round-trips, so they don't belong on the main thread either.
*/
fun setLauncherName(name: LauncherName) {
_launcherName.value = name
@@ -534,7 +556,9 @@ class SettingsViewModel @Inject constructor(
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// Same Glance-state path as setAgendaWidgetRange.
// The agenda widget honours this setting too, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
@@ -558,10 +582,12 @@ class SettingsViewModel @Inject constructor(
}
/**
* Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. The
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
* own guard reads an async-echoed snapshot.
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
* read-modify-write so a concurrent reorder can't clobber this toggle (and
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
* re-checked inside the transform: the screen's own guard reads the
* async-echoed snapshot, so two quick disable-taps could both look allowed
* yet compose to a below-minimum cycle.
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
@@ -586,8 +612,9 @@ class SettingsViewModel @Inject constructor(
}
/**
* A scan follows the write: switching reminders off cancels the scan alarm,
* so switching them back on has to arm a new one (#75).
* A scan follows the write, in that order. Switching reminders off cancels
* the scan alarm, so switching them back on has to arm a new one — nothing
* else would until a provider change or the daily worker came along (#75).
*/
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch {

View File

@@ -48,10 +48,14 @@ internal fun SpecialDatesScreen(
onBack: () -> Unit,
) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
// The all-day reminder hour lives in the general settings state; the reminder
// picker below names it, since that is when these reminders actually fire.
val settings by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
// READ_CONTACTS is requested only here, on enable — never at startup.
// READ_CONTACTS is requested only here, on enable — never at startup. On a
// grant we either enable (if turning on) or just re-sync (clearing a stalled
// banner after the permission was re-granted).
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
@@ -118,6 +122,8 @@ internal fun SpecialDatesScreen(
)
if (state.enabled) {
// Per-type card: the toggle, and while on, its title format and its
// calendar-wide reminder.
SpecialDateType.entries.forEachIndexed { index, type ->
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
val on = type in state.types
@@ -216,6 +222,8 @@ internal fun SpecialDatesScreen(
allowInherit = false,
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
onDismiss = { reminderPickerType = null },
// Special dates are all-day events, so their reminders fire at the
// one global all-day hour like any other all-day reminder.
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
)
}
@@ -260,7 +268,8 @@ private fun SpecialDatesTemplateDialog(
title = { Text(stringResource(R.string.settings_special_dates_template)) },
text = {
Column {
// The dialog convention — see DialogControls.
// The app's borderless input over a tonal surface (the dialog
// convention — see DialogControls), not Material's outlined field.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),

View File

@@ -52,9 +52,15 @@ import java.time.format.TextStyle as JavaTextStyle
* Views: everything that changes how a calendar view reads, grouped by the view
* it belongs to, plus the two cross-view ordering lists (#24, #69).
*
* The quick-switch cycle and the drawer list are independent orders — a view
* off in the cycle is still reachable from the drawer. The cycle needs at least
* [QuickSwitchConfig.MIN_ENABLED] targets.
* The first group holds what applies everywhere (default view, week start, time
* format); the rest are per-view. Anything that styles the *app* rather than a
* view (theme, fonts) is in [AppearanceScreen]; the widget's own copies of the
* agenda settings are in [WidgetsScreen].
*
* The quick-switch cycle and the navigation-drawer list are two independent
* orders — a view disabled in the quick-switch cycle is still reachable from the
* drawer, which always lists every view. The switch needs at least two targets,
* so the last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
*/
@Composable
internal fun ViewsScreen(
@@ -260,6 +266,8 @@ internal fun ViewsScreen(
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
// The same icon each view carries in the drawer and the switcher
// pill, so the row is matched to the view by eye, not by reading.
leading = {
Icon(
imageVector = it.icon,
@@ -291,8 +299,9 @@ internal fun ViewsScreen(
options = TimeFormatPref.entries,
selected = state.timeFormat,
label = { timeFormatLabel(it) },
// Each option renders the same sample time its own way;
// "Automatic" also names which of the two it resolves to.
// Each option renders the same sample time the way it would write
// it — an afternoon one, since that is where 12h and 24h diverge.
// "Automatic" additionally says which of the two it resolves to now.
summary = { pref ->
val sample = formatTimeOfDay(
hour = SAMPLE_HOUR,
@@ -326,6 +335,8 @@ internal fun ViewsScreen(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.settings_agenda_range_hint),
selected = state.agendaScreenRange,
// Resolving each option to real dates needs the same week start the
// agenda itself windows by.
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setAgendaScreenRange,
onDismiss = { showAgendaScreenRange = false },
@@ -389,7 +400,8 @@ private val WEEK_START_OPTIONS: List<WeekStartPref> =
@Composable
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
// Localised weekday name, so no per-day string resource is needed.
// Localised full weekday name, so any of the seven days reads naturally
// without a per-day string resource.
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}

View File

@@ -31,11 +31,20 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
/**
* The week-start chooser, with the grid it rearranges above it — the same
* [MonthStylePreview] the Month style picker uses, drawn in the user's own month
* style. "Follow the system" carries the day it resolves to as its summary.
* The week-start chooser, with the grid it rearranges shown above it.
*
* A preview picker, so selecting applies at once and keeps the picker open.
* Which column a week opens on is the most visual setting in the app, and the
* one hardest to hold in your head from a weekday name alone. The preview is
* the same [MonthStylePreview] the Month style picker uses, drawn in the user's
* *own* month style, so what moves is exactly what will move on the real
* screen. It is short here (the option list runs to eight rows), which the
* preview supports natively by scaling.
*
* "Follow the system" carries the day it currently resolves to as its summary —
* otherwise the one automatic option is the only one whose effect is invisible.
*
* A preview picker, so selecting applies at once and keeps the picker open;
* back exits (see `FullScreenPicker`).
*/
@Composable
internal fun WeekStartPicker(
@@ -84,6 +93,8 @@ internal fun WeekStartPicker(
val isSelected = option == selected
GroupedRow(
title = weekStartLabel(option),
// Only the automatic option needs a second line: it is the one
// whose effect the label doesn't name.
summary = if (option == WeekStartPref.Auto) {
stringResource(
R.string.settings_week_start_auto_summary,

View File

@@ -33,9 +33,13 @@ import de.jeanlucmakiola.floret.locale.currentLocale
import kotlin.math.roundToInt
/**
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick
* Settings tile shortcut. The agenda widget keeps its own range and text size,
* separate from the Agenda screen's range in [ViewsScreen].
* Widgets & tiles (#69): the home-screen widgets' own settings and the Quick
* Settings tile shortcut — the app's system surfaces, collected in one place.
*
* The agenda widget keeps its own range and text size, deliberately separate
* from the Agenda *screen*'s range in [ViewsScreen]: a widget is glanced at, a
* screen is browsed, and having the two rows sit next to each other (as they did
* before) made them easy to mistake for one another.
*/
@Composable
internal fun WidgetsScreen(
@@ -67,8 +71,9 @@ internal fun WidgetsScreen(
onClick = { showWidgetSize = true },
)
// The add-tile prompt is API 33+; below that the tile is still
// addable from the QS editor, so the row just doesn't appear.
// 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 here.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Spacer(Modifier.height(24.dp))
QuickSettingsTileRow()
@@ -93,8 +98,10 @@ internal fun WidgetsScreen(
options = WidgetSize.entries,
selected = state.widgetSize,
label = { widgetSizeLabel(it) },
// No live preview: the widget is Glance/RemoteViews, so a Compose
// mock-up would be a second implementation free to drift.
// A size name says nothing about what you get; the text scale it
// draws at does. No live preview here on purpose: the widget is
// Glance/RemoteViews, so a Compose mock-up would be a second
// implementation free to drift from the real thing.
summary = { widgetSizeSummary(it) },
onSelect = viewModel::setWidgetSize,
onDismiss = { showWidgetSize = false },
@@ -131,8 +138,9 @@ private fun requestAddQsTile(context: Context) {
}
/**
* What a size step does, as a percentage of the smallest one's event text.
* Derived from the widget's own [metricsFor] table rather than hardcoded.
* What a size step actually does, as a percentage of the smallest one's event
* text. Derived from the widget's own [metricsFor] table rather than written
* out here, so a retuned step can't leave this line claiming the old number.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {

View File

@@ -2,9 +2,19 @@ package de.jeanlucmakiola.calendula.widget
/**
* The size step the **agenda** widget draws its text and rows at — a user
* setting, not the measured size (#51), which a launcher reports inaccurately.
* The month widget keeps sizing itself from the width it is given (#103).
* setting, not something derived from the widget's measured size (#51).
*
* [SMALL] is the default and reproduces the widget's original constants.
* The agenda widget used to bucket its live size (`SizeMode.Exact` +
* `LocalSize.current`) into a tier and scale from that. The size a launcher
* reports is not the size the widget is drawn into, so the tier it picked could
* disagree with what was on screen; a size the user sets is predictable and is
* what the #51 thread actually asked for.
*
* The month widget deliberately has no size setting: its grid divides whatever
* width it is given by seven and always has, which is the behaviour to keep
* (#103).
*
* [SMALL] is the default and reproduces the widget's original constants, so an
* existing widget looks as it did until its owner turns the size up.
*/
enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }

View File

@@ -8,8 +8,10 @@ import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Horizontal layout constants for an agenda event row. These don't scale with the
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
* is derived from them so non-event text can't drift out of alignment.
* size step a wider stripe or gap would eat title width, which is the thing the
* row is short of — but [TEXT_INDENT] is *derived* from them so the day header and
* the "nothing left today" line can never drift out of alignment with the event
* title column the way a hardcoded 19dp could.
*/
internal val ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp
@@ -37,21 +39,34 @@ internal data class AgendaMetrics(
val dayHeaderTopPad: Dp, // space above a day header
) {
/**
* Resolves the stripe height against the user's system font scale: the stripe
* is a [Dp] while the lines beside it are `sp`, so without this the text
* outgrows it at large font settings.
* Resolves the stripe height against the user's system font scale.
*
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
* they scale with the accessibility font setting and the stripe does not —
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
* it is supposed to mark. Multiplying by the same factor keeps them locked,
* and at the default scale of 1.0 reproduces the step's value exactly.
*/
fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
}
/*
* Type sizes are anchored to the Material 3 type scale, with two deviations:
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
*
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
* widget's original constants verbatim (#51).
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
* steps; interpolated values are used where a role would force a ≥1.4x jump.
* Two documented deviations:
*
* ‡ SMALL's 13sp day header is off-scale. It is held there deliberately —
* SMALL reproduces the widget's original constants verbatim so a widget whose
* owner never touches the size setting looks exactly as it did (#51), and
* snapping it to Title Small (14sp) would break that promise for a 1sp gain.
*
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
* which is far too coarse for four size steps. Where a role would force a
* ≥1.4x step between adjacent steps we hold an interpolated value instead and
* mark it. The endpoints stay on real roles.
*/
private val SMALL_METRICS = AgendaMetrics(

View File

@@ -116,10 +116,14 @@ class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Single: metrics come from the chosen WidgetSize, not the measured one
// (#103, #51), so a RemoteViews per host size bucket would only double the
// payload. The row list stays capped below — an uncapped agenda could push
// the RemoteViews past the binder transaction limit.
// Single: type and row metrics come from the user's chosen WidgetSize, not
// from the widget's measured size (#103, #51), so there is nothing to gain
// from Glance building one RemoteViews per host size bucket — and plenty to
// lose, since that roughly doubles the serialized payload. The row list is
// still capped below: an uncapped agenda (the range goes up to
// AgendaRange.MAX_CUSTOM_DAYS = 365) could push the RemoteViews past the
// binder transaction limit and the host would just show "Problem loading
// widget".
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -160,8 +164,10 @@ private sealed interface AgendaRow {
@Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// Read reactively from per-instance Glance state, falling back to the saved
// pref for a freshly placed widget (#103, #51).
// Type and row metrics come from the user's chosen size step, read reactively
// from per-instance Glance state and falling back to the saved pref for a
// freshly placed widget (#103, #51). The permission screen has no loaded prefs
// to fall back to, so it takes the default.
// The stripe is then re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL

View File

@@ -408,9 +408,11 @@ class CalendarRepositoryImplTest {
fun `a flushed switch-off never reads as on again before the provider ticks`(
@TempDir tempDir: Path,
) = runTest {
// The reconciler's shape: write VISIBLE = 0, then release the id
// app-side. The provider's notification arrives afterwards, so the
// release must not be read against the pre-write snapshot.
// The reconciler's shape: write VISIBLE = 0 straight to the provider,
// then release the id app-side. The provider's notification only arrives
// afterwards (it is dispatched through the main looper), so the release
// must not be read against the snapshot from before the write — that
// would flash exactly the events being hidden back into every view.
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {

View File

@@ -53,29 +53,13 @@ class RecurrenceOccurrencesTest {
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
// The Thursday start is an occurrence in its own right (RFC 5545 puts
// DTSTART in the set), then Friday 31 Jul and Mon/Fri of the next week.
// Thursday start: the Monday of that week is already past, so the first
// hit is Friday 31 Jul, then Mon/Fri of the following weeks.
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 3),
date(2026, 8, 7),
).inOrder()
}
@Test
fun `the start counts once even when it is also one of the picks`() {
// Thursday start with Thursday picked: the seeded DTSTART and the rule's
// own hit are the same date and must not both be listed.
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.THURSDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 6),
date(2026, 8, 13),
date(2026, 8, 10),
).inOrder()
}

View File

@@ -20,7 +20,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
repository tests. Instrumented tests are a last resort.
4. **No network.** The app declares no `INTERNET` permission. Anything that
would need one is an explicit, documented product decision first
(the crash reporter's web-issue path is the worked example).
(see the roadmap's idea backlog).
## Layers
@@ -62,18 +62,11 @@ flowchart TD
- **`data/prefs/`** — DataStore-backed settings (theme, week start, form
field defaults, reminders toggle) and small state (last-used calendar).
- **`ui/`** — one package per screen, each with Screen + ViewModel +
UiState. Shared pieces in `ui/common/` (recurrence humanizer, FAB column,
drawer, transitions). Selection pickers are full-screen and come from
floret-kit (`FullScreenPicker` / `OptionPicker`); `AlertDialog` is reserved
for plain confirmations and the compact recurring-scope choosers.
`ui/settings/` is the exception to "one file per screen": one
`SettingsViewModel` feeds a hub (`SettingsScreen.kt`) plus a sub-screen per
category, each in its own `*Settings.kt`.
- **`floret-kit/`** — the shared Material 3 Expressive kit for the Floret app
family, wired in as a git submodule *and* a Gradle composite build
(`includeBuild`), so it is compiled from source rather than resolved as a
dependency. Pickers, crash plumbing, and locale/time helpers live there;
changing them is a pull request against that repository plus a submodule bump.
UiState. Shared pieces in `ui/common/` (OptionCard — the app's only
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
drawer, transitions). `ui/settings/` is the exception to "one file per
screen": one `SettingsViewModel` feeds a hub (`SettingsScreen.kt`) plus a
sub-screen per category, each in its own `*Settings.kt`.
## Navigation
@@ -90,7 +83,7 @@ exactly like an event tap.
## Recurring writes
The provider's invariants drive the design (learned the hard way, verified
on-device):
on-device — see plan 03):
- Recurring rows carry `RRULE` + `DURATION` (no `DTEND`); one-off rows
carry `DTEND`.
@@ -233,7 +226,6 @@ JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
`CalendarDataSource` is faked (`FakeCalendarDataSource` records writes),
mappers parse `ColumnReader`/plain maps instead of cursors, domain logic
(recurrence, validation, snapshots, write-value building) is pure. CI
(Forgejo Actions on Codeberg) runs `lint test assembleDebug` once per pull
request; merging a
(Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a
bumped `versionName` to `main` builds, signs, and publishes to the self-hosted
F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md.

View File

@@ -1,35 +1,12 @@
# Building from source
Calendula builds with the standard Android Gradle toolchain — no extra setup
beyond the SDK, a JDK, and the submodule.
## Clone
```bash
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
```
Calendula depends on **[floret-kit](https://codeberg.org/jlmakiola/floret-kit)**,
the shared Material 3 Expressive kit, as a git submodule wired in as a Gradle
composite build (`includeBuild("floret-kit")` in `settings.gradle.kts`) — it is
compiled from source, not resolved from a repository. A clone without the
submodule fails to configure. For an existing clone:
```bash
git submodule update --init --recursive
```
beyond the SDK and a JDK.
## Requirements
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. If your
default JDK differs, set `JAVA_HOME` explicitly.
- **Android SDK** — platform **37** (`compileSdk`) and **build-tools 36.0.0**.
`minSdk` is 29, `targetSdk` 36.
The SDK is located via `ANDROID_HOME` (or `ANDROID_SDK_ROOT`), or via a
gitignored `local.properties` with `sdk.dir`. If you use `local.properties`, note
that the composite build needs **its own** copy at `floret-kit/local.properties`;
setting `ANDROID_HOME` covers both builds at once and is the simpler route.
- **Android SDK 36+**
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
The Gradle wrapper is checked in, so you don't need a system Gradle.
@@ -37,44 +14,11 @@ The Gradle wrapper is checked in, so you don't need a system Gradle.
```bash
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
./gradlew test # JVM unit tests (JUnit 5 + Truth + Turbine)
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
A single test class, or a pattern:
```bash
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
```
Translation-key invariants (stale and orphaned keys are fatal; missing ones are
not) are checked outside Gradle:
```bash
python3 scripts/check_translations.py
```
## What CI runs
`.forgejo/workflows/ci.yaml` reports a single `CI` check per pull request on
Codeberg: the reproducible-release guard, then `lintDebug`,
`testDebugUnitTest`, `assembleDebug` and a Trivy filesystem scan. Pull requests
that touch only docs, F-Droid metadata or the licence skip the Android build and
still report green.
## Release builds
Release signing and the publishing pipeline are documented separately in
[RELEASING.md](RELEASING.md). To smoke-test a release candidate locally, the
`releaseTest` build type is an R8-shrunk twin of `release`, debug-signed with its
own `applicationId` suffix so it installs alongside the real app:
```bash
scripts/verify-release.sh
```
## Contributing
See [`../CONTRIBUTING.md`](../CONTRIBUTING.md) for the issue-first workflow,
which branch to target, and the architectural rules a change is reviewed against.
[RELEASING.md](RELEASING.md).

View File

@@ -4,22 +4,19 @@ Where to look for what:
| Document | What it is |
|---|---|
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | How to contribute: issue-first workflow, which branch to target, translations, the rules a change is reviewed against |
| [`BUILDING.md`](BUILDING.md) | Building from source: submodule, JDK/SDK requirements, Gradle tasks, what CI runs |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Orientation tour: principles, layers, navigation, recurring-write / conflict / reminder pipelines, testing |
| [`RELEASING.md`](RELEASING.md) | Release process: versioning, the merge-driven pipeline, the two-forge split, secrets, key custody |
| [`../CHANGELOG.md`](../CHANGELOG.md) | Release history (Keep a Changelog, SemVer) |
| [Issues](https://codeberg.org/jlmakiola/calendula/issues) + [milestones](https://codeberg.org/jlmakiola/calendula/milestones) | **The roadmap.** What's planned, in progress, and shipped — a milestone maps to its `release/vX.Y.Z` branch |
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is: core value, stack + version pins, constraints, naming, forge/release infrastructure |
| [`design/`](design/) | Per-feature design notes kept for features whose provider behaviour is worth recording |
| [`../.planning/ROADMAP.md`](../.planning/ROADMAP.md) | Living roadmap: shipped milestones, current scope, idea backlog |
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is, stack, naming, infrastructure |
| [`../.planning/REQUIREMENTS.md`](../.planning/REQUIREMENTS.md) | Requirement checklist per milestone |
| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands |
| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated |
| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned |
| [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth): descriptions, title, icon, screenshots (DE + EN). Harvested directly by the official F-Droid repo; transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) |
| [`../fdroid-metadata/`](../fdroid-metadata/) | App-level F-Droid control file (`*.yml`: Categories, License, links) for the self-hosted repo's `fdroid update` |
| [`fdroid-official/`](fdroid-official/) | Recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
| [`fdroid-official/`](fdroid-official/) | Draft recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
Conventions: planning lives in the **issue tracker**, not in this repository. The
`.planning/` files that predated it (a roadmap, a development-state snapshot, and
a per-milestone requirement checklist) are gone — issues and milestones say the
same thing without going stale. `PROJECT.md` is what remains, and it describes
the project rather than its plan.
`ARCHITECTURE.md` is the authoritative orientation tour: it is updated with the
code, and is the right place for a lesson learned about the calendar provider.
Conventions: plans and specs under `superpowers/` are point-in-time
artifacts of the agentic workflow that built each milestone — they get
status updates but are never rewritten. The `.planning/` files are living
documents and should stay current.

View File

@@ -87,9 +87,6 @@ release work when a merge actually cuts a release:
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
(both best-effort). Ordinary merges with no version bump fall through `detect`
and do nothing.
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to
Google Play. Runs last and separately so a Play rejection can't endanger a
release that already shipped; skips cleanly until Play is configured.
### Codeberg direct-download channel
@@ -105,64 +102,6 @@ unset. One-time setup: the repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) added
to Gitea Actions.
### Google Play channel
Play is a third channel alongside F-Droid and the Codeberg download, and it is
the only one that gets a different artifact and a different signature.
**Artifact.** Play takes an **App Bundle** (`bundleRelease`), not the APK. It is
a second output of the same source and the same signing config — never a
repackage of the published APK, which stays untouched so the F-Droid
reproducibility guarantee is unaffected. The bundle is built at the very **end**
of the `release` job, after everything else has shipped, and both it and the
handoff to the `play` job are `continue-on-error` — nothing Play-related may
take down a release that is already published. The handoff uses a patched
`upload-artifact`/`download-artifact` fork pinned to a commit: the official v4
actions read any non-github.com forge as an unsupported GHES instance and refuse
to run on Gitea (go-gitea/gitea#36024). `dependenciesInfo` stays disabled for
the bundle too; Play's "app dependencies" report is optional and re-enabling it
would break reproducibility.
**Signature — read this before assuming an update path exists.** Play App
Signing is mandatory for new apps, and Google generates and holds the app
signing key. The release keystore in CI is registered only as the **upload
key**: Play verifies uploads with it, then re-signs with Google's key before
delivery. Consequences, accepted deliberately:
- A Play install and an F-Droid install have **different signatures** and
**cannot update each other**. Switching channels requires uninstall +
reinstall, which loses nothing (all data lives in the system calendar
provider) but must be stated wherever both channels are advertised.
- Losing the upload key is **recoverable** — request an upload-key reset in the
Play Console. Losing the app key still is not, for F-Droid.
**Build and signing are not fastlane's job.** Gradle does both, exactly as
before. fastlane appears only as the Play Developer API client (`supply`),
because the store listing already lives in `fastlane/metadata/android/` — the
same tree the official F-Droid repo harvests. One metadata source, two stores.
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (generated
from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is
**not** touched — an accidental overwrite of a live listing triggers a Play
policy review. Sync it deliberately with `bundle exec fastlane listing`.
**Screenshots and graphics are skipped**, because the committed assets satisfy
F-Droid but not Play:
| Asset | Committed | Play requires |
| --- | --- | --- |
| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha |
| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG |
| `featureGraphic.png` | *missing* | required, exactly 1024×500 |
Until those are fixed, Play's graphics are managed by hand in the console. Then
pass `images:true` to the `listing` lane.
**Track.** Uploads go to `internal` by default; promotion to production stays a
manual action in the Play Console, matching the rule that UI releases wait for
on-device review. Override with the `PLAY_TRACK` repo variable.
### Manual re-sign / recovery
A manual `workflow_dispatch` of the release workflow runs a **re-sign-only**
@@ -214,15 +153,6 @@ Two consequences worth remembering:
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console access — uploads the AAB. If unset, the `play` job skips cleanly. |
### Variables (Gitea → repo Settings → Actions → Variables)
| Variable | Default | Purpose |
| --- | --- | --- |
| `PLAY_TRACK` | `internal` | Play track the bundle is uploaded to. |
| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. |
| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit against the API and discards it — use to rehearse. |
The two keys are independent: the **app key** signs APKs; the **repo key**
signs the index (its fingerprint is what users pin). Neither key nor the

View File

@@ -1,7 +0,0 @@
# fastlane is used in this repo ONLY as a Google Play Developer API client
# (see fastlane/Fastfile). It never builds and never signs.
# Reconstructed in CI from the PLAY_SERVICE_ACCOUNT_JSON secret; gitignored.
json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json")
package_name("de.jeanlucmakiola.calendula")

View File

@@ -1,80 +0,0 @@
# Google Play publishing only.
#
# Building and signing are deliberately NOT fastlane's job: release.yaml drives
# Gradle directly so the release build stays F-Droid-reproducible (`vcsInfo`,
# `dependenciesInfo` and the AGP metadata block are disabled in
# app/build.gradle.kts and guarded by scripts/check_reproducible_release.sh).
# Interposing fastlane there would add a layer that can inject Gradle flags into
# the one build whose byte-for-byte output is verified by a third party.
#
# What fastlane IS here for is `supply` — the Play Developer API client — because
# the store listing already lives in fastlane/metadata/android, which the
# official F-Droid repo harvests from the tagged source tree. One metadata tree,
# two stores.
default_platform(:android)
platform :android do
desc "Upload an already-built, already-signed AAB to Play"
lane :deploy do |options|
aab = options[:aab] || "app/build/outputs/bundle/release/app-release.aab"
UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab)
supply(
aab: aab,
track: options[:track] || "internal",
release_status: options[:release_status] || "completed",
# The APK belongs to F-Droid and the Codeberg download; Play only ever
# gets the bundle. Never let the uploader reach for the other artifact.
skip_upload_apk: true,
# Listing text is synced deliberately via the `listing` lane, not on every
# release. Overwriting a live listing by accident triggers a Play policy
# review, which is not a quick revert.
skip_upload_metadata: true,
# "What's New" DOES track every release.
# scripts/sync_changelog_to_fastlane.sh writes
# fastlane/metadata/android/en-US/changelogs/<versionCode>.txt from
# CHANGELOG.md, so Play shows exactly the text F-Droid does.
skip_upload_changelogs: false,
# See the `listing` lane for why the committed images can't go to Play yet.
skip_upload_images: true,
skip_upload_screenshots: true,
# Dry run: validate the edit against the API and roll it back instead of
# committing it. Used for the first end-to-end rehearsal.
validate_only: options[:dry_run].to_s == "true",
)
end
desc "Sync the store listing text (and, once the assets qualify, the images)"
lane :listing do |options|
# Deliberate and manual: `bundle exec fastlane listing`. Not wired into the
# release pipeline.
#
# Images stay off by default because the committed assets do not satisfy
# Play today, even though F-Droid accepts all of them:
#
# * phoneScreenshots are 1280x2856. Play caps the long edge at twice the
# short edge (2560 here), so every screenshot is rejected.
# * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha.
# * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG.
# * there is no featureGraphic.png. Play requires one, exactly 1024x500.
#
# Until that is fixed, the Play listing's graphics are managed by hand in the
# console. Pass images:true once the assets qualify.
upload_images = options[:images].to_s == "true"
supply(
skip_upload_aab: true,
skip_upload_apk: true,
skip_upload_metadata: false,
skip_upload_changelogs: true,
skip_upload_images: !upload_images,
skip_upload_screenshots: !upload_images,
)
end
end

View File

@@ -1,128 +0,0 @@
### Added
- Settings → Calendars now says what is different about a calendar instead of
leaving you to guess. Ones you can only view — a subscribed calendar, a
calendar shared with you read-only — are marked **Read-only** ([#76]).
- Calendars your device isn't syncing are marked **Not synced**, moved to the
bottom of their account and left without a switch. None of their events are on
the device, so the switch they used to have could not have shown you anything
— the calendar simply looked broken. They are no longer offered when you pick
a calendar for a new or an imported event either: an event saved there would
never reach the account. Whether an account syncs a calendar stays that
account's own app's decision ([#78]).
- The birthday and anniversary calendars Calendula fills from your contacts are
marked **Filled from your contacts**, which is why they can't be picked for a
new event: anything you put there would be removed again on the next sync.
Deleting one is held back while special dates are switched on — Calendula
would simply create it again — and the calendar's editor says so; turn the
feature off under Settings → Special dates and the delete works as usual
([#76]).
- The calendar picker in the event form and in the .ics import screen now ends
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]).
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
the widget used to make from its own measurements. Small is what it looks like
today, so nothing changes until you turn it up ([#51]).
- A repeating event now shows you its next few dates, not just a description of
the rule. Both the preset list and the custom recurrence picker carry a
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
can mean something other than it sounds like, and only the dates say so. A
rule that can never fire says that instead ([#69]).
- The event visibility options now say who they affect. **Public**, **Private**
and **Confidential** each carry a line about what other people on a shared
calendar see — the part the four words on their own leave out ([#69]).
### Changed
- **Settings has been reorganised so each setting sits where you would look for
it.** One long undifferentiated list is now three labelled groups — Look &
behaviour, Data, and App — whose rows open sub-screens and say what they do
rather than only naming themselves. Appearance, Views, New event form,
Notifications and the new Widgets & tiles are separate screens now, so the
settings for a calendar view are no longer mixed in with the ones for the
app's colours or for the home-screen widgets ([#69]).
- Settings that are hard to picture from their name now show you what they do.
The week-start picker rearranges a real month grid as you choose, the
past-events setting previews a sample agenda day for Show, Dim and Hide, the
font pickers set a specimen line in the face you are choosing, and the Agenda
range options carry the dates each one actually covers. Options that follow
the system additionally name which way they currently fall ([#69]).
- **Backup & restore** is now its own Settings entry instead of living inside
the calendar manager, where it was easy to miss — keeping a copy of your
calendars is a different question from which calendars you have. The calendar
manager keeps a row pointing to it, and nothing about how backup or automatic
backup works has changed ([#69]).
- Calendula's source code now lives on **Codeberg**, where its issues already
were. The **Source code** and **License** links in Settings → About point
there, so reporting a bug and reading the code no longer land on two different
sites. Nothing about the app itself changes, and the F-Droid repository is
unaffected.
### Fixed
- A month-grid widget stays a month grid, and draws all seven days again. Since
2.16.0 a placed month widget could redraw itself as the agenda widget a little
after any change to your events, and could draw only about four day columns
with the last one cut off part-way through. Both came from the release build
merging the two widgets into a single class, so Android could no longer tell
which of them a widget on your home screen was — and the month grid was handed
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
- The back gesture on **Settings → Views** returns to Settings instead of
leaving Settings altogether and dropping you on the calendar. Special dates
did the same ([#81]).
- The dots standing in for the events that didn't fit a day in the month view
now dim with everything else when **Dim completed events** is on. A past day
with four or more events kept its last events at full strength while the rest
faded ([#79]).
- Two accounts that happen to share a name — a Google account and a DAVx5
account for the same address, say — are no longer merged into one group.
They were listed together in Settings → Calendars and in the drawer's filter,
which also meant the group's source icon and its "manage in app" button could
send you to the wrong app, "toggle all" spanned both accounts at once, and
collapsing one collapsed the other. Where a name really is shared, each group
now names the app it comes from ([#77]).
- In the month view's **Split** style, the new-event button now starts on the
day you have selected. It always started on today, whichever day was selected
and listed below the grid ([#87]).
- Search results now show an all-day event's real date. West of UTC — anywhere in
the Americas, say — a search hit was dated one day early, disagreeing with the
day the month, week and agenda views file the same event under ([#82]).
- Reminders no longer depend on Android telling Calendula when they are due.
Calendula now works out each reminder's time itself and sets its own alarm for
it. On some phones — Samsung's among them — the system's calendar storage never
sends the signal a calendar app is meant to wake up on, and no amount of
battery or notification settings helps: the reminder is simply never announced.
None of that is visible from inside an app that waits to be told, which is why
it took a second pass to find ([#75]).
Reminders also survive things that used to lose them quietly. After a restart
or an app update Calendula re-arms its alarms, and a reminder whose moment
passed while the phone was off still arrives, as long as the event has not
ended yet.
- All-day reminders now arrive at the time you chose in **Settings →
Notifications**, on every occurrence. A yearly birthday could drift an hour
either way depending on daylight saving, and all-day reminders on calendars
from an account fired in the middle of the night instead of in the morning
([#75]).
- Reminders now arrive for every calendar you have switched on. A calendar that
was hidden at system level — switched off in another calendar app, or never
switched on after being added — still showed its events and listed their
reminders in Calendula, but never notified: Android only schedules reminder
alarms for calendars marked visible, and Calendula kept its own separate
on/off list that had no say in it. There is now one switch: **Settings →
Calendars** turns a calendar on or off for the whole device, so what you see
and what reminds you can no longer disagree ([#75]).
Calendars you had switched off in Calendula are switched off here too on first
launch. Calendars that were already off — hidden in another calendar app, or
never switched on after being added — stay off, and Calendula says so once
rather than quietly switching them on for every app on your device; you can
turn any of them back on in Settings → Calendars.
If you gave Calendula read-only access to your calendars, the switch still
works: your choice is kept in the app until it can be written.
The drawer's filter is unchanged and still app-only: hiding a calendar there
tidies your view without silencing its reminders.

View File

@@ -7,20 +7,20 @@ coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
composeBom = "2026.05.01"
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
# Pin explicitly to override the BOM (which ships stable 1.4.0).
# Re-evaluate when 1.5.0 stable lands.
material3 = "1.5.0-alpha24"
material3 = "1.5.0-alpha21"
datastore = "1.2.1"
junit = "6.1.2"
junitPlatform = "6.1.2"
junit = "6.1.0"
junitPlatform = "6.1.0"
truth = "1.4.5"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2"
turbine = "1.2.1"
turbine = "1.2.0"
hiltNavigationCompose = "1.3.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"