Compare commits

..

10 Commits

Author SHA1 Message Date
Jean-Luc Makiola
4e51e33356 2.17.1 — re-cut 2.17.0 after the release pipeline dropped it (#109)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m0s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m5s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/109
2026-07-30 22:42:58 +02:00
Jean-Luc Makiola
e8657117d6 2.17.0: reminders Calendula delivers itself, one visibility model, and a Settings you can navigate (#108)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 6m29s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/108
2026-07-30 22:14:57 +02:00
Jean-Luc Makiola
d5c53df6b5 Publish the release bundle to Google Play (#84) (#100)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/100
2026-07-30 13:59:43 +02:00
Jean-Luc Makiola
2f4e05899a Make the Obtainium badge add the app in one tap (#99)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/99
2026-07-30 11:39:06 +02:00
06653bf5b1 fix(deps): update composebom to v2026.06.01 (#95)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/95
2026-07-30 11:27:24 +02:00
e952393116 fix(deps): update test dependencies (#92)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/92
2026-07-30 11:27:09 +02:00
384ba39cf9 fix(deps): update material3 (alpha) to v1.5.0-alpha24 (#91)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
fix(deps): update material3 (alpha) to v1.5.0-alpha24 #91
2026-07-30 11:26:18 +02:00
Jean-Luc Makiola
3fca28810b Don't run the Android build for forge-housekeeping changes (#98)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
CI already skips the Android build for docs-only pull requests, and that part works — a README-only PR reports green in ~11s. The skip-list was just too narrow to catch a realistic docs PR.

PR #96 is the example: a contributing guide, docs corrections and issue templates. It ran the full lint + test + assemble four times, over exactly two files Gradle never reads — `.forgejo/ISSUE_TEMPLATE/config.yml` and `.gitignore`. Added to the list: issue templates, `.planning/`, `licenses/`, `renovate.json5`, `.gitignore`, `.gitattributes`, `.editorconfig`.

It stays a skip-list rather than a build-list, so an unfamiliar path still builds by default; the workflows themselves, `.gitmodules` and `scripts/` are deliberately not skippable. The step now also prints which files forced the build, so the next "why did my docs PR build?" is answerable from the log.

One latent bug alongside it: `github.base_ref` is normalised against a `refs/heads/` prefix. Arriving in full-ref form would fail the merge-base lookup and quietly degrade the guard into "always build" — the failure mode this PR is fixing, but permanently and invisibly.

Verified by replaying the new pattern over real history: PR #96 and #97 now skip; the forge migration (workflows), the search fix (app code) and a Weblate translation merge still build.

No issue — reported directly.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/98
2026-07-30 11:22:23 +02:00
Jean-Luc Makiola
c988e1f028 Fix the broken README screenshot and spell out the install channels (#97)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
The screenshot strip linked `05-edit.png`, which doesn't exist — replaced with the real agenda screenshot, and the unused day-view shot is now shown too (six screenshots, all present).

Install is now one section per channel — official F-Droid, self-hosted F-Droid repo, Codeberg release / Obtainium, Google Play (coming soon), build from source — with an overview table up front and the previously missing "Get it on Obtainium" badge in the header. The badge links to the Codeberg releases page rather than an `obtainium://` deep link, because Forgejo's markdown sanitiser strips custom URL schemes.

The Play section notes that Play builds carry Google's signing key (so switching channels needs an uninstall) and asks for closed-testing testers.

Also corrects two stale claims: the UI is no longer German-and-English only (nine translations exist), and the language list now says so.

No issue — reported directly.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/97
2026-07-30 11:22:11 +02:00
Jean-Luc Makiola
a3d136f55d Write a contributing guide, and fix what the forge move left stale (#96)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 27s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
(docs) Update all the docs to be up to date with current status quo
2026-07-30 10:40:07 +02:00
108 changed files with 5341 additions and 3969 deletions

View File

@@ -0,0 +1,30 @@
---
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

@@ -0,0 +1,18 @@
# 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

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

View File

@@ -0,0 +1,41 @@
<!--
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,14 +37,29 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
# 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`.
- 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.
@@ -58,11 +73,15 @@ jobs:
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
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"
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
echo "code=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Java

View File

@@ -1,23 +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:

View File

@@ -1,4 +1,4 @@
name: Release — F-Droid repo + Gitea/Codeberg release
name: Release — F-Droid repo + Gitea/Codeberg release + Play
# 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,6 +9,12 @@ name: Release — F-Droid repo + Gitea/Codeberg release
# 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
@@ -477,3 +483,168 @@ 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,11 +50,27 @@ 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,9 +17,10 @@ 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. Next is v3.0
(power-user features) plus an undecided "Locations & People" idea backlog;
see `ROADMAP.md`.
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.
## Stack
@@ -27,10 +28,26 @@ 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.1.1, Gradle 9.5.1. JVM target 17.
with Version Catalog. AGP 9.2.1, Gradle 9.5.1. JVM target 17 (exactly — AGP
requires it).
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
any feature that would need one is an explicit product decision first.
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.
## Naming
@@ -40,5 +57,9 @@ shows a stylized "1" on a slate squircle.
## Source
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
Hetzner. Same infrastructure as `HouseHoldKeaper`.
**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`.

View File

@@ -1,53 +0,0 @@
# 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

View File

@@ -1,587 +0,0 @@
# 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.

View File

@@ -1,177 +0,0 @@
# 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,6 +7,8 @@ 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
@@ -28,8 +30,39 @@ 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 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
@@ -37,6 +70,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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]).
@@ -1188,4 +1245,11 @@ automatically, with zero telemetry and no internet permission.
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
[#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

184
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,184 @@
# 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 Normal file
View File

@@ -0,0 +1,10 @@
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,15 +18,18 @@ 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="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">
<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">
</p>
</div>
@@ -68,24 +71,38 @@ database, no sync stack reinvented.
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting — and [open to community
translations](#-translations)
- 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)
- **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/).
### Self-hosted repo (latest builds)
F-Droid rebuilds from source on its own schedule, so a new version usually
shows up there a few days after release.
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:
### 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:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
@@ -100,19 +117,71 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds:
2. Refresh, search for **Calendula**, install.
Both channels share the same signing key, so you can switch between them
without reinstalling. Or build from source — see below.
### 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>
## 📚 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
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
- **[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.
## 🌍 Translations
Calendula ships in German and English, and you're warmly invited to add your
language. Translations are managed on a self-hosted **Weblate**:
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**:
**→ [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 = 21600
versionName = "2.16.0"
versionCode = 21701
versionName = "2.17.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -38,3 +38,15 @@
# SessionWorker never ran, and widgets were stuck on their loading layout
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
-keep class * extends androidx.work.InputMerger { <init>(...); }
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
# GlanceAppWidgetManager persists a providerName -> receivers map under that
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
# same overrides, no distinguishing members — were horizontally merged into one
# class, so both receivers registered under the *same* provider name and
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
# placed month widget as the agenda one on the next data change (#89). Keeping
# the real names also survives app updates, which would otherwise renumber the
# obfuscated name and orphan the stored mapping.
-keep class * extends androidx.glance.appwidget.GlanceAppWidget

View File

@@ -47,12 +47,9 @@ class CalendulaApp : Application() {
}
/**
* 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.
* 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.
*/
private fun startReminderDelivery() {
val deps = EntryPointAccessors.fromApplication(
@@ -65,12 +62,9 @@ class CalendulaApp : Application() {
}
/**
* 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.
* 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.
*/
private fun reconcileCalendarVisibility() {
val deps = EntryPointAccessors.fromApplication(
@@ -101,10 +95,8 @@ class CalendulaApp : Application() {
/**
* Re-arm (or cancel) the daily special-dates reconcile from the saved
* 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.
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
* ON_RESUME trigger, so no immediate run is needed here.
*/
private fun reconcileSpecialDates() {
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)

View File

@@ -1,11 +1,10 @@
package de.jeanlucmakiola.calendula.data.calendar
import java.time.Instant
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Translates an all-day reminder between the **semantic** lead time the UI
@@ -73,19 +72,13 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
* and rows written under a different timezone. A negative [rawMinutes] (fire
* after DTSTART) folds to day 0.
* [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.
*/
internal fun fromProviderAllDayMinutes(
rawMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
): Int {
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
.atZone(zone).toLocalDate()
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
}
timeOfDayMinutes: Int,
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY

View File

@@ -54,7 +54,12 @@ import javax.inject.Singleton
interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
/**
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
* decode their stored offsets back to whole-day lead times (see
* [fromProviderAllDayMinutes]).
*/
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
/**
* Master/one-off events whose title, description or location contains
@@ -112,19 +117,15 @@ interface CalendarDataSource {
/**
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
* 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.
* 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.
*/
fun setCalendarVisible(id: Long, visible: Boolean)
/**
* 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.
* 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.
*/
fun isCalendarVisible(id: Long): Boolean?
@@ -400,14 +401,11 @@ 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` 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.
* 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.
*/
override fun setCalendarVisible(id: Long, visible: Boolean) {
val values = ContentValues().apply {
@@ -670,7 +668,7 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun eventDetail(eventId: Long): EventDetail? {
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
return resolver.query(
@@ -679,7 +677,9 @@ class AndroidCalendarDataSource @Inject constructor(
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) null
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
else CursorColumnReader(c).toEventDetailCore(
attendees, reminders, allDayReminderTimeMinutes,
)
}
}

View File

@@ -31,9 +31,7 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
// 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.
// NULL is treated as syncing — the harmless default.
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
)

View File

@@ -39,16 +39,12 @@ interface CalendarRepository {
suspend fun deleteCalendar(id: Long)
/**
* 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).
* 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.
*
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]),
* where it filters events and reminders just the same until it can be
* written.
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
*/
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)

View File

@@ -68,23 +68,17 @@ 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 (an id
* leaves it as its `VISIBLE` write lands, which changes what is shown).
* [calendarsSnapshot] keeps the two in step.
* notifications, plus every change to the pending switch-off set.
*/
private fun visibilityTicks(): Flow<Unit> = merge(
ticks.onStart { emit(Unit) },
// 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).
// drop(1): the current value is already covered by the tick above.
prefs.pendingDisabledCalendarIds.drop(1).map {},
)
// 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.
// 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.
override fun calendars(): Flow<List<CalendarSource>> =
visibilityTicks().reQuery {
val calendars = calendarsSnapshot()
@@ -94,19 +88,13 @@ 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 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.
// 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.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine(
visibilityTicks().reQuery {
@@ -127,10 +115,8 @@ 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 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.
// Any DataStore edit re-emits the hidden set even when unchanged;
// collapse those so views don't re-render for them.
.distinctUntilChanged()
.flowOn(io)
@@ -151,21 +137,14 @@ class CalendarRepositoryImpl @Inject constructor(
private var cachedCalendars: List<CalendarSource> = emptyList()
/**
* 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.
* 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.
*
* 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.
* 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.
*/
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
val current = generation.get()
@@ -179,7 +158,8 @@ class CalendarRepositoryImpl @Inject constructor(
}
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
?: throw NoSuchEventException(eventId)
}
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {

View File

@@ -21,25 +21,15 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* 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
* 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
* `WRITE_CALENDAR`.
*
* 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.
* 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.
*/
@Singleton
class CalendarVisibilityReconciler @Inject constructor(
@@ -50,43 +40,30 @@ class CalendarVisibilityReconciler @Inject constructor(
) {
suspend fun run() = withContext(io) {
// 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.
// 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.
try {
// 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.
// Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade.
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()
// 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.
// Empty means "couldn't read" (null cursor), not "no calendars".
// Both decisions below are one-way, so leave them 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]). Dropping each id as it
// lands keeps a part-applied run resumable.
// [CalendarDataSource.setCalendarVisible]).
for (id in plan.hide) {
dataSource.setCalendarVisible(id, false)
prefs.removePendingDisabledCalendarIds(setOf(id))
@@ -100,9 +77,7 @@ class CalendarVisibilityReconciler @Inject constructor(
/**
* Settle the one-time notice: [pending] arms it, false retires it unshown.
* 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.
* Answered once and stored either way, so it can't resurface later.
*/
private suspend fun settleNoticeOnce(pending: Boolean) {
if (prefs.visibilityNoticePending.first() != null) return
@@ -111,10 +86,7 @@ class CalendarVisibilityReconciler @Inject constructor(
/**
* Whether this install has ever run an earlier version. The notice explains
* 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.
* a behaviour change, so a first install has nothing to announce.
*/
private fun isUpgradeInstall(): Boolean = try {
@Suppress("DEPRECATION")

View File

@@ -24,6 +24,7 @@ private const val TAG = "EventDetailMapper"
internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>,
reminders: List<Reminder>,
allDayReminderTimeMinutes: Int,
): EventDetail? {
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
@@ -89,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
val zone = ZoneId.systemDefault()
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
reminders.map {
it.copy(
minutes = fromProviderAllDayMinutes(
it.minutes, startDate, zone, allDayReminderTimeMinutes,
),
)
}
} else {
reminders
}

View File

@@ -29,10 +29,8 @@ class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
// 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.
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
// unrelated write would otherwise re-emit an identical set.
val hiddenCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
@@ -42,17 +40,13 @@ class CalendarPrefs @Inject constructor(
}
/**
* 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.
* 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.
*
* 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.
* 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.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
@@ -62,9 +56,8 @@ class CalendarPrefs @Inject constructor(
editPendingDisabled { it + ids }
/**
* 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).
* 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.
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it - ids.toSet() }

View File

@@ -10,19 +10,13 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* How far reminder delivery has got. One number, and it replaces everything the
* provider's `CalendarAlerts.STATE` used to do for us (#75).
* 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.
*
* 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.
* Unset means "never scanned", not zero: the first scan after an install would
* otherwise treat every reminder since the epoch as overdue.
*/
@Singleton
class ReminderStatePrefs @Inject constructor(

View File

@@ -23,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -306,6 +307,23 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
}
/**
* The size step the agenda widget draws its text at (#51). Defaults to
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
* widget is unchanged until its owner turns the size up.
*
* This replaced deriving a size tier from the widget's measured size, which
* the launcher does not report reliably. The month widget takes no size
* setting — it divides the width it is given by seven (#103).
*/
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
}
suspend fun setWidgetSize(size: WidgetSize) {
store.edit { it[WIDGET_SIZE_KEY] = size.name }
}
/**
* Whether the agenda shows its top range bar — the "showing …" header and
* the session range switcher (v2.11). Default ON.
@@ -781,6 +799,7 @@ class SettingsPrefs @Inject constructor(
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
internal val AGENDA_SHOW_TODAY_KEY =
booleanPreferencesKey("agenda_show_today")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
@@ -91,10 +92,15 @@ class ReminderActionReceiver : BroadcastReceiver() {
private const val EXTRA_LOCATION = "location"
private const val EXTRA_ALL_DAY = "all_day"
/** An explicit intent to this receiver carrying [alert] as extras. */
/**
* 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.
*/
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action
data = "calendula://reminder/${alert.key}".toUri()
putExtra(EXTRA_ALERT_KEY, alert.key)
putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
@@ -106,13 +112,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
}
/**
* 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 only make two
* *different* reminders collide, which the intent extras then separate
* (PendingIntents compare their intents too, not just the request code).
* 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.
*/
fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) {

View File

@@ -19,17 +19,10 @@ 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 — 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).
* 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).
*/
@Singleton
class ReminderAlarmScheduler @Inject constructor(

View File

@@ -4,14 +4,9 @@ import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
/**
* One reminder as the notification layer needs it: what to show, and the stable
* [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.
* [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).
*/
data class ReminderAlert(
val key: Long,

View File

@@ -9,16 +9,11 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* The read side of in-house reminder delivery: occurrences and the reminder
* offsets hanging off them, straight out of the provider's own tables.
* 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).
*
* 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.
* An interface so [ReminderScanner] can be exercised on the JVM.
*/
interface ReminderInstanceSource {
@@ -46,11 +41,9 @@ class ProviderReminderInstanceSource @Inject constructor(
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `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.
// `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.
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
@@ -78,8 +71,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 because the ids go into the selection literally; an unbounded
// `IN (...)` on a busy calendar would grow the SQL past what SQLite takes.
// Batched: the ids go into the selection literally, and an unbounded
// `IN (...)` 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,13 +15,8 @@ import java.util.concurrent.TimeUnit
/**
* The backstop under the alarm: a daily scan that runs whether or not the alarm
* 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.
* 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).
*/
object ReminderMaintenanceScheduler {
@@ -55,8 +50,8 @@ class ReminderMaintenanceWorker(
.scan()
Result.success()
} catch (e: Exception) {
// The scan swallows its own failures; anything reaching here is the
// entry point itself, which a retry will not mend. Never fail the chain.
// The scan swallows its own failures, so anything reaching here is the
// entry point itself a retry will not mend it.
Log.w(TAG, "Reminder maintenance scan failed", e)
Result.success()
}

View File

@@ -54,12 +54,10 @@ class ReminderNotifier @Inject constructor(
}
/**
* 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.
* 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].
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
@@ -123,7 +121,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: re-running it would hit the same revoked permission.
// Handled either way — a retry hits the same revoked permission.
return true
}
@@ -142,8 +140,7 @@ class ReminderNotifier @Inject constructor(
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
// 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.
// Shares the per-(alert, action) request-code scheme with the buttons.
/* requestCode = */ ReminderActionReceiver.requestCode(
alert, ReminderActionReceiver.ACTION_OPEN,
),

View File

@@ -33,13 +33,9 @@ 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.
*
* 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.
* 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.
*/
@Singleton
class ReminderScanner @Inject constructor(
@@ -68,8 +64,7 @@ class ReminderScanner @Inject constructor(
try {
runScan()
} catch (e: SecurityException) {
// The calendar permission was revoked mid-flight. Nothing to
// re-arm and nothing to recover from — the next grant re-scans.
// Permission revoked mid-flight; 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)
@@ -82,18 +77,16 @@ 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 everything missed meanwhile.
// Reminders off: drop the wake-up, but keep the watermark moving
// so switching them back on doesn't replay the backlog.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// 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.
// 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.
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders(
instances = occurrences,
@@ -112,8 +105,8 @@ class ReminderScanner @Inject constructor(
if (notifier.canPost()) {
schedule.due.forEach { notifier.post(it.toAlert()) }
}
// Advance regardless of whether anything could be posted: a user who
// muted notifications is not owed a backlog when they unmute.
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
@@ -123,13 +116,9 @@ class ReminderScanner @Inject constructor(
) == PackageManager.PERMISSION_GRANTED
/**
* 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.
* 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.
*/
fun startWatchingProvider() {
if (watching) return
@@ -159,9 +148,8 @@ class ReminderScanner @Inject constructor(
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
/**
* 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.
* 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.
*/
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000

View File

@@ -11,22 +11,13 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Every reason to re-run a reminder scan that arrives from outside the process.
* 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.
*
* 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.
* Exported for the system broadcasts; an early scan triggered by another app is
* harmless.
*/
@AndroidEntryPoint
class ReminderScheduleReceiver : BroadcastReceiver() {
@@ -34,10 +25,9 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
@Inject lateinit var scanner: ReminderScanner
override fun onReceive(context: Context, intent: Intent) {
// 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.
// 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.
if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {

View File

@@ -10,17 +10,13 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
* 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.
*
* 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).
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
* exact-alarm capability (API 3132 with the permission revoked).
*/
@Singleton
class ReminderSnoozeScheduler @Inject constructor(
@@ -40,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 3132): an inexact wake is the honest
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
// Exact alarms revoked (API 3132); an inexact wake is the best
// available without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)

View File

@@ -1,15 +1,13 @@
package de.jeanlucmakiola.calendula.domain
/**
* 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).
* The ways a calendar can behave unlike a plain, writable one — each a reason it
* is missing from the event and import pickers (#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,
* which is why it is the one exclusion with nothing else to give it away.
* yet no event target: anything authored here is deleted by the next sync.
*/
MANAGED,
@@ -21,22 +19,17 @@ enum class CalendarStateLabel {
}
/**
* 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.
* 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`.
*/
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: there are no events on the device
* to reveal, so the switch would be a control that does nothing.
* would see — it can't for a non-syncing one, with no events on the device.
*/
val CalendarSource.hasVisibilitySwitch: Boolean
get() = !isNotSynced
@@ -44,18 +37,9 @@ 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):
*
* - 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.
* 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.
*/
val CalendarSource.isEventTarget: Boolean
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced

View File

@@ -12,22 +12,16 @@ data class CalendarVisibilityPlan(
}
/**
* 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
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
* what the retired app-local visibility model left behind (#75) — against the
* calendars actually on the device.
*
* 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]).
* 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]).
*
* [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.
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
* hidden, or gone from the device.
*/
fun calendarVisibilityPlan(
calendars: List<CalendarSource>,
@@ -38,8 +32,7 @@ fun calendarVisibilityPlan(
val settled = mutableSetOf<Long>()
for (id in pendingDisabledIds) {
val calendar = byId[id]
// No row means the calendar is gone; already invisible means someone
// (us, on an earlier run) got there first. Either way: nothing to write.
// Gone from the device, or already invisible — nothing to write.
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
}
return CalendarVisibilityPlan(hide = hide, settled = settled)
@@ -47,10 +40,7 @@ fun calendarVisibilityPlan(
/**
* Whether any calendar is switched off at system level without Calendula having
* 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.
* asked for it — the condition the one-time notice explains.
*/
fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,

View File

@@ -13,11 +13,9 @@ data class CalendarSource(
val accountType: String,
val color: Int,
/**
* 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.
* 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.
*/
val isVisibleInSystem: Boolean,
/**
@@ -47,10 +45,8 @@ data class CalendarSource(
val isManaged: Boolean = false,
/**
* Whether the provider keeps this calendar's events on the device
* (`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.
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
* nothing about device-local calendars, which can hold events with it off.
* Read for the "not synced" row label (#76).
*/
val syncsEvents: Boolean = true,
@@ -77,12 +73,9 @@ data class EventInstance(
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
/**
* 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.
* 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.
*/
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
@@ -92,9 +85,8 @@ fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* 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].
* 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].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start

View File

@@ -0,0 +1,106 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.minus
import kotlinx.datetime.number
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.
*
* 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.
*
* 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]).
*/
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
if (limit <= 0) return emptyList()
val until = (end as? RecurrenceEnd.Until)?.date
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)
var period = 0
// Periods can yield nothing (a skipped 31st), so the cap counts periods
// examined rather than dates found.
while (result.size < wanted && period < MAX_PERIODS) {
for (date in occurrencesInPeriod(period, start)) {
if (date <= start) continue
if (until != null && date > until) return result
result += date
if (result.size == wanted) return result
}
period++
}
return result
}
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
when (freq) {
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
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".
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
}
RecurrenceFreq.Yearly ->
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
}
/**
* One weekly block: every picked weekday inside the week that begins
* `period * interval` weeks after the start's own week, in weekday order. With
* no picks the rule simply repeats the start's weekday.
*/
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
DAYS_PER_WEEK
val weekStart = start
.minus(daysIntoWeek, DateTimeUnit.DAY)
.plus(period * interval, DateTimeUnit.WEEK)
return byDays.sortedBy { it.isoDayNumber }.map { day ->
weekStart.plus(
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
DateTimeUnit.DAY,
)
}
}
/**
* 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.
*/
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
runCatching { LocalDate(year, month, day) }.getOrNull()
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.
*/
private const val MAX_PERIODS = 2_000

View File

@@ -8,21 +8,8 @@ import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* 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.
* 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.
*/
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
@@ -46,11 +33,8 @@ data class PlannedReminder(
val alarmMillis: Long,
) {
/**
* 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.
* Stable identity, keying the notification tag and the snooze/dismiss
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
*/
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
@@ -75,28 +59,10 @@ private const val MINUTES_PER_DAY = 1_440
/**
* Pair every instance with each of its event's reminder offsets.
*
* 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.
* 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.
*/
fun planReminders(
instances: List<ReminderEventInstance>,
@@ -124,30 +90,44 @@ private fun allDayDate(beginMillis: Long): LocalDate =
/**
* How many whole days before its occurrence a raw all-day offset means.
*
* A plain multiple of 1440 is read at face value. That covers rows from other
* calendar apps, which carry no encoded hour — and it stays right for our own
* rows that happen to land on a multiple, because those encode a wall-clock hour
* equal to the sampled UTC offset, so the day count is the same either way.
* 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.
*
* Anything else is one of ours, with an hour folded in: recover the day count the
* way [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes]
* does for display, by asking which local date the encoded instant falls on.
* Keeping the two in step is what makes the notification arrive on the day the
* event screen says it will.
* Also used by
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
* display, so screen and notification agree.
*/
internal fun allDayLeadDays(rawMinutes: Int, beginMillis: Long, zone: ZoneId): Long {
if (rawMinutes % MINUTES_PER_DAY == 0) return (rawMinutes / MINUTES_PER_DAY).toLong()
val encoded = Instant.ofEpochMilli(beginMillis - rawMinutes * MILLIS_PER_MINUTE)
return ChronoUnit.DAYS.between(encoded.atZone(zone).toLocalDate(), allDayDate(beginMillis))
internal fun allDayLeadDays(
rawMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
allDayTimeMinutes: Int,
): Long {
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
val minutesFromNamedHour = encoded.toLocalTime().let {
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
minOf(delta, MINUTES_PER_DAY - delta)
}
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
return (rawMinutes / MINUTES_PER_DAY).toLong()
}
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
}
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
private fun allDayAlarmMillis(
beginMillis: Long,
rawMinutes: Int,
zone: ZoneId,
allDayTimeMinutes: Int,
): Long = allDayDate(beginMillis)
.minusDays(allDayLeadDays(rawMinutes, beginMillis, zone))
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
.atZone(zone)
.toInstant()
@@ -156,17 +136,10 @@ 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]` — 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.
* 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.
*/
fun scheduleReminders(
planned: List<PlannedReminder>,
@@ -196,24 +169,23 @@ fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* 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.
* 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.
*/
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
/**
* 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).
* 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.
*/
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
lookaheadMillis + maxOf(0L, maxReminderMinutes * MILLIS_PER_MINUTE)
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
/** Longest reminder offset a scan stretches its query window for — one year. */
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000

View File

@@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -148,6 +149,10 @@ 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.
var showBackup by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out.
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
@@ -210,6 +215,7 @@ fun CalendarHost(
fun dismissCoveringOverlays() {
showSettings = false
showCalendars = false
showBackup = false
detailKey = null
editKey = null
importUri = null
@@ -293,8 +299,8 @@ fun CalendarHost(
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null ||
importForm != null
editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
}
@@ -449,6 +455,7 @@ fun CalendarHost(
SettingsScreen(
onBack = { showSettings = false },
onManageCalendars = { showCalendars = true },
onOpenBackup = { showBackup = true },
)
}
@@ -478,10 +485,8 @@ fun CalendarHost(
)
}
// 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.
// Declared last so it covers every overlay that can open it: Settings,
// both event forms, and the .ics import picker (#76).
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
@@ -489,12 +494,27 @@ fun CalendarHost(
) {
CalendarsScreen(
onBack = { showCalendars = false },
// The manager opens the import too (restore from backup), and
// that way round it 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 the
// manager once the import is done.
onImport = { importUri = it; importForceMany = true; showCalendars = false },
onOpenBackup = { showBackup = true },
)
}
// Backup & restore — over the manager, since the manager links into it.
AnimatedVisibility(
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
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
},
)
}
}

View File

@@ -51,6 +51,10 @@ 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.
val grantedAtLaunch = remember { hasPermission }
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
@@ -82,13 +86,15 @@ 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, shown over the app.
// visibility (#75), armed by the reconciler.
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
// 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() }
// Runs on entry however the permission was granted, including via
// Android's app-settings screen (caught by the ON_RESUME above).
LaunchedEffect(Unit) {
visibilityNotice.reconcile()
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
}
if (onboardingDone == true && noticePending) {
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
}

View File

@@ -92,20 +92,27 @@ 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)
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
return when {
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
range == AgendaRange.ThisMonth && !monthAsSpan ->
localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"

View File

@@ -93,6 +93,7 @@ fun AgendaScreen(
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -198,6 +199,7 @@ fun AgendaScreen(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.agenda_range_override_hint),
selected = successState?.range ?: AgendaRange.Month,
weekStart = weekStart,
onSelect = viewModel::setRangeOverride,
onDismiss = { showRangePicker = false },
)

View File

@@ -48,8 +48,12 @@ class AgendaViewModel @Inject constructor(
settingsPrefs.agendaShowRangeBar,
) { range, showBar -> AgendaSettings(range, showBar) }
// First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
/**
* First day of the week, for the calendar-aligned "this week" range. Public
* because the range picker resolves each option to real dates, which needs
* the same week start the window is built from.
*/
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
/**
* How to treat events that already ended today (show / dim / hide). A display
@@ -90,7 +94,7 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
AgendaParams(
anchor = anchor,
range = override ?: settings.range,

View File

@@ -0,0 +1,373 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
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.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
"text/plain",
)
/**
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
* [onImport] hands a picked file to the app's normal .ics import flow.
*/
@Composable
fun BackupScreen(
onBack: () -> Unit,
onImport: (Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
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.
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.
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.
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// The VM persists the write grant so background runs can keep writing.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
is BackupResult.Success -> {
snackbarHostState.showSnackbar(
context.resources.getQuantityString(
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
),
)
viewModel.consumeBackupResult()
}
BackupResult.Failure -> {
snackbarHostState.showSnackbar(backupFailedText)
viewModel.consumeBackupResult()
}
null -> Unit
}
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
) {
HintText(stringResource(R.string.calendars_backup_hint))
if (exportable.isNotEmpty()) {
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// A single exportable calendar skips the selector.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
}
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = viewModel::setAutoBackupIntervalMinutes,
onDismiss = { showInterval = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
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.
var selected by rememberSaveable(
stateSaver = listSaver(
save = { it.toList() },
restore = { it.toSet() },
),
) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
}
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
@Composable
private fun rememberFolderName(uriString: String?): String? {
val context = LocalContext.current
return remember(uriString) {
uriString?.let {
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
}
}
}
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
@Composable
private fun backupIntervalLabel(minutes: Long): String {
val duration = when {
minutes % MINUTES_PER_WEEK == 0L ->
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
minutes % MINUTES_PER_DAY == 0L ->
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
minutes % 60L == 0L ->
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
else ->
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
}
return stringResource(R.string.calendars_auto_backup_every, duration)
}
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
stringResource(R.string.calendars_auto_backup_status_failed, relative)
}
}
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
@Composable
private fun BackupIntervalDialog(
currentMinutes: Long,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// 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)
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.calendars_auto_backup_interval_min),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = {
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
onDismiss()
}) { Text(stringResource(R.string.reminder_custom_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L

View File

@@ -23,10 +23,8 @@ import javax.inject.Inject
/**
* The one-time notice that Calendula now follows the device's per-calendar
* 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.
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
* switch those calendars back on — that would un-hide them everywhere else too.
*/
@HiltViewModel
class CalendarVisibilityNoticeViewModel @Inject constructor(
@@ -35,12 +33,9 @@ class CalendarVisibilityNoticeViewModel @Inject constructor(
) : ViewModel() {
/**
* 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.
* 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.
*/
fun reconcile() {
viewModelScope.launch { reconciler.run() }

View File

@@ -4,9 +4,6 @@ import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
@@ -26,26 +23,22 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -67,89 +60,68 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
// 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",
"text/plain",
)
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* 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. A
* full-screen destination; [onBack] pops it.
* calendars with create / rename / recolor / delete, and synced calendars
* read-only with a per-account "manage in the source app" deep-link.
*
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
* screen only points at it. [onBack] pops the destination.
*/
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
onImport: (android.net.Uri) -> Unit,
onOpenBackup: () -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
@@ -187,14 +159,7 @@ fun CalendarsScreen(
synced = calendars.filterNot { it.isLocal },
error = error,
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onImport = onImport,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
onOpenBackup = onOpenBackup,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
@@ -210,14 +175,7 @@ private fun CalendarsList(
synced: List<CalendarSource>,
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
onImport: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
onSetAutoBackupInterval: (Long) -> Unit,
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
onOpenBackup: () -> Unit,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
@@ -228,7 +186,7 @@ private fun CalendarsList(
val snackbarHostState = remember { SnackbarHostState() }
// Accounts the user has folded shut; empty = all expanded (keeps every
// calendar visible by default, the section is collapsible for tidiness).
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
var localExpanded by remember { mutableStateOf(true) }
val writeErrorText = stringResource(R.string.calendars_write_error)
@@ -239,47 +197,6 @@ private fun CalendarsList(
}
}
// 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 { onExportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// 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) }
// 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(onSetAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
is BackupResult.Success -> {
snackbarHostState.showSnackbar(
context.resources.getQuantityString(
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
),
)
onConsumeBackupResult()
}
BackupResult.Failure -> {
snackbarHostState.showSnackbar(backupFailedText)
onConsumeBackupResult()
}
null -> Unit
}
}
CollapsingScaffold(
title = stringResource(R.string.calendars_title),
onBack = onBack,
@@ -332,82 +249,22 @@ private fun CalendarsList(
}
}
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any calendar the import picker would offer
// (local or synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.isEventTarget }
if (exportable.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
// One connected card: the one-time export on top, 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 = {
// 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 {
showExportPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = {
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
},
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
},
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
// Pointer to the Backup entry (#69), so it stays findable from here.
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_section_backup),
summary = stringResource(R.string.settings_backup_subtitle),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.Backup) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// 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.
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
}
},
onClick = onOpenBackup,
)
Spacer(Modifier.height(16.dp))
@@ -417,10 +274,11 @@ private fun CalendarsList(
SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint))
synced
.groupBy { it.accountName.ifBlank { it.accountType } }
.forEach { (account, cals) ->
val expanded = account !in collapsedAccounts
val accountType = cals.first().accountType
.groupByAccount()
.forEach { group ->
val cals = group.calendars
val expanded = group.key !in collapsedAccounts
val accountType = group.accountType
// A non-syncing calendar has no switch, so it neither counts
// towards "the whole account is off" nor moves with toggle-all.
val switchable = cals.filter { it.hasVisibilitySwitch }
@@ -428,7 +286,7 @@ private fun CalendarsList(
switchable.none { it.isVisibleInSystem }
Spacer(Modifier.height(16.dp))
CalendarGroup(
title = account,
title = accountGroupTitle(group),
expanded = expanded,
bodyHasRows = true,
headerDisabled = accountDisabled,
@@ -440,9 +298,9 @@ private fun CalendarsList(
},
onToggleExpand = {
collapsedAccounts = if (expanded) {
collapsedAccounts + account
collapsedAccounts + group.key
} else {
collapsedAccounts - account
collapsedAccounts - group.key
}
},
showToggleAll = switchable.isNotEmpty(),
@@ -451,8 +309,8 @@ private fun CalendarsList(
onSetAccountVisible(switchable.map { it.id }, enabled)
},
) {
// Calendars you can act on first; the ones this device isn't
// syncing sit at the bottom, dimmed and switchless.
// Actionable calendars first; non-syncing ones at the
// bottom, dimmed and switchless.
val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
@@ -482,93 +340,6 @@ private fun CalendarsList(
}
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = onSetAutoBackupInterval,
onDismiss = { showInterval = false },
)
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = local.filter { it.canModifyContents && !it.isManaged },
onExport = onExportBackup,
onDismiss = { showExportPicker = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (android.net.Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// 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() },
restore = { it.toSet() },
),
) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -616,11 +387,8 @@ private fun CalendarEditor(
},
actions = {
if (!isNew) {
// 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.
// Disabled rather than hidden while the special-dates
// sync owns this calendar; the card below says why.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
@@ -747,9 +515,7 @@ 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. 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.
* plain writable one (#76), then its own description.
*/
@Composable
private fun calendarRowSummary(calendar: CalendarSource): String? {
@@ -767,12 +533,9 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
}
/**
* 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.
* 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.
*/
@Composable
private fun EnableSwitch(
@@ -954,113 +717,31 @@ private fun CalendarGroupMenu(
@Composable
private fun SectionHeader(text: String) {
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
// The cards' own edge, so header and group share a left margin.
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
private fun HintText(text: String) {
internal fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
@Composable
private fun rememberFolderName(uriString: String?): String? {
val context = LocalContext.current
return remember(uriString) {
uriString?.let {
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
}
}
}
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
@Composable
private fun backupIntervalLabel(minutes: Long): String {
val duration = when {
minutes % MINUTES_PER_WEEK == 0L ->
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
minutes % MINUTES_PER_DAY == 0L ->
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
minutes % 60L == 0L ->
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
else ->
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
}
return stringResource(R.string.calendars_auto_backup_every, duration)
}
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
stringResource(R.string.calendars_auto_backup_status_failed, relative)
}
}
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
@Composable
private fun BackupIntervalDialog(
currentMinutes: Long,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// 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)
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.calendars_auto_backup_interval_min),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = {
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
onDismiss()
}) { Text(stringResource(R.string.reminder_custom_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L
/**
* Pick the app to open for managing a synced calendar's account. The account's

View File

@@ -73,18 +73,10 @@ class CalendarsViewModel @Inject constructor(
)
/**
* 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.
* 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.
*/
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
calendars,
@@ -147,24 +139,18 @@ class CalendarsViewModel @Inject constructor(
}
/**
* 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.
*
* Nothing has to be re-posted on the way back on: reminder delivery plans
* from `Instances` and `Reminders` on every scan (#75), and the provider
* change this write makes triggers one.
* 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).
*/
fun setCalendarVisible(id: Long, visible: Boolean) = write {
repository.setCalendarsVisible(listOf(id), visible)
}
/**
* 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.
* 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.
*/
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
repository.setCalendarsVisible(ids, visible)

View File

@@ -0,0 +1,80 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
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.
*/
data class CalendarAccountGroup(
/** Stable identity: what makes two calendars belong to the same account. */
val key: AccountKey,
/** The account's own name, as shown when it is unambiguous. */
val label: String,
/** True when another group shows the same [label] under a different type. */
val ambiguous: Boolean,
val calendars: List<CalendarSource>,
) {
val accountType: String get() = key.type
}
/** The pair a group is keyed on. */
data class AccountKey(val name: String, val type: String)
/**
* Group [calendars] under their owning account, preserving the provider's order
* within each group and ordering groups by first appearance.
*
* The label falls back through name → type → the first calendar's own name, so
* a calendar with no account still lands somewhere sensible.
*/
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
val labels = grouped.mapValues { (key, cals) ->
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
}
val shared = labels.values.groupingBy { it }.eachCount()
return grouped.map { (key, cals) ->
val label = labels.getValue(key)
CalendarAccountGroup(
key = key,
label = label,
ambiguous = shared.getValue(label) > 1,
calendars = cals,
)
}
}
/**
* What to write above a group: its account name, qualified with the app the
* account comes from when another account shares the name (#77).
*/
@Composable
fun accountGroupTitle(group: CalendarAccountGroup): String =
if (!group.ambiguous) {
group.label
} else {
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
}
/**
* The human name of the app backing [accountType], falling back to the raw
* account type when no installed app resolves for it.
*/
@Composable
fun sourceAppName(accountType: String): String {
val context = LocalContext.current
return remember(accountType) {
val pm = context.packageManager
val packages = sourceAppPackages(context, accountType)
packages.firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
}
}

View File

@@ -45,11 +45,9 @@ 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 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.
* 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.
*/
@Composable
fun ColumnScope.CalendarPickerGroups(
@@ -60,9 +58,7 @@ fun ColumnScope.CalendarPickerGroups(
) {
val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) {
calendars.filterNot { it.isLocal }
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
.toList()
calendars.filterNot { it.isLocal }.groupByAccount()
}
if (local.isNotEmpty()) {
@@ -74,12 +70,12 @@ fun ColumnScope.CalendarPickerGroups(
onSelect = onSelect,
)
}
syncedGroups.forEachIndexed { index, (account, cals) ->
syncedGroups.forEachIndexed { index, group ->
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
CalendarPickerGroup(
title = account,
leading = { SourceLogo(cals.first().accountType) },
calendars = cals,
title = accountGroupTitle(group),
leading = { SourceLogo(group.accountType) },
calendars = group.calendars,
selectedId = selectedId,
onSelect = onSelect,
)
@@ -181,20 +177,22 @@ fun LeadingAvatar(icon: ImageVector) {
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager
val candidates = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
for (pkg in sourceAppPackages(context, accountType)) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Apps that could stand for [accountType], best candidate first. */
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
/** Preferred app for account types whose authenticator isn't the app to open. */
internal fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"

View File

@@ -23,16 +23,26 @@ import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Reminder-default picker, full-screen and **multi-select**: each [presets]
@@ -48,6 +58,10 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
* row expands an inline number field plus a unit selector to add an arbitrary
* 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.
*/
@Composable
fun ReminderDefaultPicker(
@@ -57,6 +71,7 @@ fun ReminderDefaultPicker(
allowInherit: Boolean,
onSelect: (ReminderOverride) -> Unit,
onDismiss: () -> Unit,
leadTimeSummary: (@Composable (Int) -> String?)? = null,
) {
// Optimistic local state: once the user edits, the chosen override is
// authoritative while the picker is open, so quick successive toggles compose
@@ -132,6 +147,7 @@ fun ReminderDefaultPicker(
val checked = minute in selectedMinutes
GroupedRow(
title = reminderLeadTimeLabel(minute),
summary = leadTimeSummary?.invoke(minute),
position = positionOf(index, rowCount),
selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
@@ -196,14 +212,15 @@ private fun CustomReminderEditor(
)
}
/** A short explanatory paragraph shown under a picker's title, above the rows. */
/** A short explanatory paragraph shown under a picker's title, above the rows,
* on the same left margin as the rows themselves. */
@Composable
internal fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
@@ -213,12 +230,17 @@ internal fun PickerDescription(text: String) {
* options (today / this week / this month) and the rolling windows (next 7 / 30
* days), with a "Custom" row that expands an inline day-count editor (1365).
* 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.
*/
@Composable
fun AgendaRangePicker(
title: String,
description: String,
selected: AgendaRange,
weekStart: DayOfWeek,
onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit,
) {
@@ -232,10 +254,21 @@ fun AgendaRangePicker(
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
}
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.
val windowSummary: (AgendaRange) -> String = { range ->
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow(
title = agendaRangeLabel(option),
summary = windowSummary(option),
position = position,
selected = isSelected,
trailing = if (isSelected) {
@@ -270,6 +303,9 @@ 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.
summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
trailing = if (customSelected) {

View File

@@ -8,6 +8,11 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
@@ -98,6 +103,33 @@ 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.
*/
@Composable
fun nextOccurrencesText(
rule: SimpleRecurrence,
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]).
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
val formatter = localizedDateFormatter(locale, pattern)
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
return stringResource(
R.string.event_edit_recurrence_next,
ListFormatter.getInstance(locale).format(formatted),
)
}
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
private const val NEXT_OCCURRENCE_COUNT = 3
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {

View File

@@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
@@ -240,7 +241,12 @@ private fun SectionHeader(text: String) {
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}

View File

@@ -157,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -514,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 field holding focus
// 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 focused field 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) }
@@ -1123,10 +1124,8 @@ private fun EventEditContent(
null -> Unit
}
// 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.
// 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.
LaunchedEffect(showCalendarPicker) {
if (showCalendarPicker) {
focusManager.clearFocus(force = true)
@@ -1142,11 +1141,9 @@ private fun EventEditContent(
viewModel.setCalendar(it)
showCalendarPicker = false
},
// 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.
// 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.
onManageCalendars = onManageCalendars?.let { openManager ->
{
showCalendarPicker = false
@@ -1172,7 +1169,7 @@ private fun EventEditContent(
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
startDay = form.start.date.dayOfWeek,
startDate = form.start.date,
firstDayOfWeek = firstDayOfWeek,
onSelect = { rrule ->
viewModel.setRecurrence(rrule)
@@ -1340,15 +1337,20 @@ private enum class RecurrenceEndMode { Never, Until, Count }
* and an end condition — only that step needs an OK button. A rule the
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
* user picks something here.
*
* 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.
*/
@Composable
private fun RecurrencePickerDialog(
current: String?,
startDay: DayOfWeek,
startDate: LocalDate,
firstDayOfWeek: DayOfWeek,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
val startDay = startDate.dayOfWeek
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
val isPlainPreset = parsed != null && parsed.interval == 1 &&
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
@@ -1400,16 +1402,19 @@ private fun RecurrencePickerDialog(
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
}
val customResult: String? = if (interval != null && customEnd != null) {
// 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.
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
SimpleRecurrence(
freq = freq,
interval = interval,
end = customEnd,
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
).toRRule()
)
} else {
null
}
val customResult: String? = customRule?.toRRule()
FullScreenPicker(
title = stringResource(R.string.event_detail_recurrence),
@@ -1440,6 +1445,7 @@ private fun RecurrencePickerDialog(
RecurrenceFreq.entries.forEachIndexed { index, entry ->
GroupedRow(
title = stringResource(recurrencePresetLabel(entry)),
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
position = positionOf(index + 1, rowCount),
selected = isPlainPreset && parsed?.freq == entry,
trailing = if (isPlainPreset && parsed?.freq == entry) {
@@ -1491,6 +1497,17 @@ 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.
Text(
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
// How often: an interval amount plus a frequency segmented row —
// all four units on show, no unit hidden behind a dropdown.
GroupedSurface(
@@ -2007,6 +2024,9 @@ 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.
*/
@Composable
private fun VisibilityPickerDialog(
@@ -2019,6 +2039,7 @@ private fun VisibilityPickerDialog(
options = AccessLevel.entries.toList(),
selected = selected,
label = { stringResource(accessLevelLabel(it)) },
summary = { stringResource(accessLevelSummary(it)) },
onSelect = onSelect,
onDismiss = onDismiss,
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
@@ -2111,6 +2132,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
AccessLevel.Confidential -> R.string.event_access_confidential
}
/** What each level means for people the calendar is shared with. */
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
AccessLevel.Default -> R.string.event_access_default_summary
AccessLevel.Public -> R.string.event_access_public_summary
AccessLevel.Private -> R.string.event_access_private_summary
AccessLevel.Confidential -> R.string.event_access_confidential_summary
}
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
@Composable
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)

View File

@@ -233,11 +233,9 @@ 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 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.
// 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.
val ownCalendar = resolvedCalendar?.takeIf { own ->
own.canModifyContents && external.writable.none { it.id == own.id }
}

View File

@@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
@@ -61,7 +62,15 @@ private fun FilterList(
Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group ->
Text(
text = group.account,
text = if (group.ambiguous) {
stringResource(
R.string.calendars_account_from_source,
group.account,
sourceAppName(group.accountType),
)
} else {
group.account
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),

View File

@@ -13,9 +13,17 @@ sealed interface FilterUiState {
data class Success(val groups: List<AccountGroup>) : FilterUiState
}
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
/**
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
*
* [accountType] and [ambiguous] carry what the header needs to tell two
* same-named accounts from different apps apart (#77); the label itself is
* built in the UI layer, which is where the source app's name can be looked up.
*/
data class AccountGroup(
val account: String,
val accountType: String,
val ambiguous: Boolean,
val calendars: List<CalendarRow>,
)

View File

@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -31,14 +32,13 @@ class FilterViewModel @Inject constructor(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
// 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.
// Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else {
FilterUiState.Success(groupByAccount(enabled, hidden))
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -60,30 +60,27 @@ class FilterViewModel @Inject constructor(
}
/**
* Group calendars under their owning account, preserving the provider's order
* within each group and ordering groups by first appearance. A calendar is
* "visible" when its id is *not* in [hidden].
* Group calendars under their owning account — by name *and* type, so two
* accounts that merely share a name stay apart (#77) — preserving the
* provider's order within each group and ordering groups by first appearance.
* A calendar is "visible" when its id is *not* in [hidden].
*/
internal fun groupByAccount(
internal fun groupCalendarsForFilter(
calendars: List<CalendarSource>,
hidden: Set<Long>,
): List<AccountGroup> =
calendars
.groupBy { it.accountLabel() }
.map { (account, cals) ->
AccountGroup(
account = account,
calendars = cals.map { c ->
CalendarRow(
id = c.id,
displayName = c.displayName,
color = c.color,
visible = c.id !in hidden,
)
},
)
}
/** Account header text: the account name, falling back to its type. */
private fun CalendarSource.accountLabel(): String =
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
calendars.groupByAccount().map { group ->
AccountGroup(
account = group.label,
accountType = group.accountType,
ambiguous = group.ambiguous,
calendars = group.calendars.map { c ->
CalendarRow(
id = c.id,
displayName = c.displayName,
color = c.color,
visible = c.id !in hidden,
)
},
)
}

View File

@@ -175,10 +175,8 @@ private fun ManyContent(
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
// 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).
// No calendar to import into; carries the same way out the picker's footer
// offers below (#76).
if (state.calendars.isEmpty()) {
CenteredMessage(
message = stringResource(R.string.import_no_calendar),

View File

@@ -86,9 +86,7 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings,
)
else -> {
// 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.
// The same targets the event form offers ([isEventTarget]).
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,

View File

@@ -137,6 +137,7 @@ import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.math.abs
import kotlin.time.Clock
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@@ -364,10 +365,14 @@ fun MonthScreen(
todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday,
onCreate = {
// Anchor on today when its month is shown, else the 1st.
// Split has a selected day; the other styles anchor on
// today when its month is shown, else the 1st.
onCreateEvent(
if (isOnCurrentMonth) today
else LocalDate(titleMonth.year, titleMonth.month, 1),
when {
viewStyle == MonthViewStyle.Split -> selectedDate
isOnCurrentMonth -> today
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
},
null,
)
},
@@ -1277,7 +1282,7 @@ internal fun SplitMonthGrid(
SplitDayCell(
date = day,
events = seated,
hidden = (week.countByDay[day] ?: 0) - seated.size,
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
isToday = day == state.today,
// A page marks only the days its own month owns. Paging
// moves the selection before this month's replacement
@@ -1318,7 +1323,7 @@ private fun SplitDayCell(
date: LocalDate,
events: List<EventInstance>,
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
hidden: Int,
hidden: List<EventInstance>,
isToday: Boolean,
isSelected: Boolean,
inMonth: Boolean,
@@ -1436,9 +1441,15 @@ private fun SplitDayCell(
* bar with no dot to grow out of.
*/
@Composable
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
private fun SplitDots(
date: LocalDate,
events: List<EventInstance>,
hidden: List<EventInstance>,
dark: Boolean,
) {
if (events.isEmpty()) return
val soften = LocalSoftenColors.current
val dimCutoff = LocalDimCutoff.current
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -1461,18 +1472,21 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
modifier = Modifier
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE)
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(event.color, dark, soften), CircleShape),
)
}
if (hidden > 0) {
if (hidden.isNotEmpty()) {
// Tagged, not lifted: this count and the expanded grid's dot row are
// the same marker on the same day, so it travels with its cell like
// everything else rather than riding above the grid on its own layer.
Text(
text = "+$hidden",
text = "+${hidden.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
modifier = Modifier
.morphBounds(MonthMorphKey.Overflow(date))
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
)
}
}
@@ -1870,15 +1884,15 @@ private fun MonthWeekRow(
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenColors = buildList {
val hiddenEvents = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) }
timed.drop(pillsShown.size).forEach { add(it.color) }
}.distinct().take(3)
.forEach { add(it.event) }
addAll(timed.drop(pillsShown.size))
}
OverflowDots(
colors = hiddenColors,
extra = hidden - hiddenColors.size,
events = hiddenEvents,
total = hidden,
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
@@ -2058,37 +2072,51 @@ private fun MonthBar(
}
}
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
/**
* 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).
*/
@Composable
private fun OverflowDots(
colors: List<Int>,
extra: Int,
events: List<EventInstance>,
total: Int,
dark: Boolean,
modifier: Modifier = Modifier,
) {
val soften = LocalSoftenColors.current
val dimCutoff = LocalDimCutoff.current
val byColor = events.groupBy { it.color }
val dots = byColor.keys.take(3)
Row(
modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
colors.forEach { argb ->
dots.forEach { argb ->
Box(
modifier = Modifier
.size(6.dp)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(argb, dark, soften), CircleShape),
)
}
val extra = total - dots.size
if (extra > 0) {
Text(
text = "+$extra",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
)
}
}
}
/** True when dimming is on and every one of [events] is already over. */
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
@Composable
private fun MonthGridLoading() {
val shape = MaterialTheme.shapes.medium

View File

@@ -67,6 +67,21 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
return byLane.filterNotNull()
}
/**
* 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).
*/
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
return buildList {
spans.forEach { span ->
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
}
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
}
}
/**
* State for the continuous style (#38): a vertical stream of *self-contained*
* months rather than one undifferentiated run of weeks. Each month is keyed by

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 showing
// the app instead: it has to cover the grants made outside it too.
// The visibility reconcile a grant owes (#75) hangs off RootScreen, which
// also catches grants made outside the app.
fun onGranted() {
_state.value = PermissionUiState.Granted
}

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -19,6 +20,7 @@ import javax.inject.Inject
@HiltViewModel
class ReminderOnboardingViewModel @Inject constructor(
private val prefs: SettingsPrefs,
private val scanner: ReminderScanner,
) : ViewModel() {
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
@@ -34,6 +36,18 @@ class ReminderOnboardingViewModel @Inject constructor(
viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
// Nothing else re-arms the scan: turning reminders off cancels the
// alarm (#75).
scanner.scan()
}
}
/**
* Re-scan after the calendar permission is granted. The launch scan runs
* before the grant and bails out without arming anything, so without this
* the first alarm waits on the daily worker.
*/
fun rearmAfterGrant() {
scanner.scanInBackground()
}
}

View File

@@ -223,10 +223,9 @@ private fun searchSummary(event: EventInstance): String {
val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
}
// 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.
// 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.
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())

View File

@@ -0,0 +1,505 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* Appearance: how the app itself looks — theme and colour, the two typeface
* roles, and the launcher name. Anything that only changes how a *calendar view*
* reads (week start, time format, grid options) lives in [ViewsScreen] instead,
* and the widget-only settings in [WidgetsScreen] (#69).
*/
@Composable
internal fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showTheme by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) }
var showAppName by remember { mutableStateOf(false) }
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
LaunchedEffect(Unit) {
viewModel.fontImportFailed.collect {
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
}
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
predictiveBack = true,
) {
// Theme & colour
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = themeLabel(state.themeMode),
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (state.dynamicColorAvailable) {
stringResource(R.string.settings_dynamic_color_summary)
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Middle,
trailing = {
Switch(
checked = state.dynamicColor,
onCheckedChange = viewModel::setDynamicColor,
enabled = state.dynamicColorAvailable,
)
},
onClick = if (state.dynamicColorAvailable) {
{ viewModel.setDynamicColor(!state.dynamicColor) }
} else {
null
},
)
GroupedRow(
title = stringResource(R.string.settings_soften_colors),
summary = stringResource(R.string.settings_soften_colors_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.softenColors,
onCheckedChange = viewModel::setSoftenColors,
)
},
onClick = { viewModel.setSoftenColors(!state.softenColors) },
)
Spacer(Modifier.height(16.dp))
// Fonts — the two Material typeface roles (#19).
GroupedRow(
title = stringResource(R.string.settings_font_headings),
summary = fontLabel(fonts.brand),
position = Position.Top,
onClick = { showBrandFont = true },
)
GroupedRow(
title = stringResource(R.string.settings_font_body),
summary = fontLabel(fonts.plain),
position = Position.Bottom,
onClick = { showPlainFont = true },
)
Spacer(Modifier.height(16.dp))
// App name — the launcher label (#44); its own group, being a
// launcher concern rather than app styling.
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
position = Position.Alone,
onClick = { showAppName = true },
)
}
if (showAppName) {
FullScreenPicker(
title = stringResource(R.string.settings_app_name),
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.
Text(
text = stringResource(R.string.settings_app_name_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
LauncherName.entries.forEach { option ->
AppNameOptionCard(
name = option,
selected = launcherName == option,
onClick = { viewModel.setLauncherName(option) },
modifier = Modifier.weight(1f),
)
}
}
}
}
if (showTheme) {
// No thumbnails: picking a theme repaints the app itself. Only which
// way "Follow the system" currently falls needs spelling out.
val systemDark = isSystemInDarkTheme()
OptionPicker(
title = stringResource(R.string.settings_theme),
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
predictiveBack = true,
options = ThemeMode.entries,
selected = state.themeMode,
label = { themeLabel(it) },
summary = { mode ->
if (mode == ThemeMode.SYSTEM) {
stringResource(
R.string.settings_theme_system_summary,
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
)
} else {
null
}
},
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
}
if (showBrandFont) {
FontPicker(
title = stringResource(R.string.settings_font_headings),
role = FontRole.BRAND,
selected = fonts.brand,
stamp = fonts.brandStamp,
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
onDismiss = { showBrandFont = false },
)
}
if (showPlainFont) {
FontPicker(
title = stringResource(R.string.settings_font_body),
role = FontRole.PLAIN,
selected = fonts.plain,
stamp = fonts.plainStamp,
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
onDismiss = { showPlainFont = false },
)
}
}
@Composable
private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
},
)
/** The summary label for a stored font token (issue #19). */
@Composable
private fun fontLabel(token: String): String = when (token) {
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
?: stringResource(R.string.settings_font_system)
}
/** The display name for a launcher-label choice (issue #44). */
@Composable
private fun launcherNameLabel(name: LauncherName): String = stringResource(
when (name) {
LauncherName.CALENDULA -> R.string.app_name
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
},
)
/**
* MIME types offered to the document picker so it lists only font files. Covers
* the modern `font/` types plus the legacy `application/` font aliases some
* providers still report. Anything that slips through is still validated by
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
*/
private val FONT_PICKER_MIME_TYPES = arrayOf(
"font/ttf",
"font/otf",
"font/sfnt",
"font/collection",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-sfnt",
"application/vnd.ms-opentype",
)
/**
* 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.
*/
@Composable
private fun FontPicker(
title: String,
role: FontRole,
selected: String,
stamp: Int,
onSelect: (String) -> Unit,
onImport: (Uri) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
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.
val customPreview = remember(role, isCustom, stamp) {
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
}
val selectedFamily = remember(role, selected, stamp) {
resolveFontFamily(selected, role, context)
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
FontSpecimen(role = role, family = selectedFamily)
FontOptionRow(
label = stringResource(R.string.settings_font_system),
preview = FontFamily.Default,
selected = selected == FONT_SYSTEM_TOKEN,
position = positionOf(0, rowCount),
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
)
BundledFont.entries.forEachIndexed { index, font ->
FontOptionRow(
label = stringResource(font.labelRes),
preview = font.family,
selected = selected == font.token,
position = positionOf(index + 1, rowCount),
onClick = { onSelect(font.token) },
)
}
FontOptionRow(
label = if (isCustom) {
stringResource(R.string.settings_font_custom_selected)
} else {
stringResource(R.string.settings_font_choose_file)
},
preview = customPreview,
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
selected = isCustom,
position = positionOf(rowCount - 1, rowCount),
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
)
}
}
/**
* 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.
*/
@Composable
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
val isBrand = role == FontRole.BRAND
val style = if (isBrand) {
MaterialTheme.typography.headlineMedium
} else {
MaterialTheme.typography.bodyLarge
}
Text(
text = stringResource(
if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body,
),
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.
minLines = 2,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
/**
* 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.
*/
@Composable
private fun FontOptionRow(
label: String,
preview: FontFamily?,
selected: Boolean,
position: Position,
onClick: () -> Unit,
leadingIcon: ImageVector? = null,
) {
GroupedRow(
title = label,
position = position,
selected = selected,
leading = {
if (preview != null) {
Text(
text = "Ag",
fontFamily = preview,
style = MaterialTheme.typography.titleLarge,
)
} else if (leadingIcon != null) {
Icon(imageVector = leadingIcon, contentDescription = null)
}
},
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}
/**
* One selectable launcher-name preview in the App name picker (#44): the app's
* launcher mark over the name, framed as a card.
*/
@Composable
private fun AppNameOptionCard(
name: LauncherName,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(24.dp)
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
}
val containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
Column(
modifier = modifier
.clip(shape)
.background(containerColor)
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
.clickable(onClick = onClick)
.padding(vertical = 20.dp, horizontal = 16.dp),
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.
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(18.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = launcherNameLabel(name),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
)
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
.then(
if (selected) {
Modifier
} else {
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
},
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp),
)
}
}
}
}

View File

@@ -0,0 +1,103 @@
package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/** New event form: which fields it opens with, and how it behaves. */
@Composable
internal fun EventFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_event_form),
onBack = onBack,
predictiveBack = true,
) {
SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp))
val fields = EventFormField.entries
fields.forEachIndexed { index, field ->
val checked = field in state.defaultFormFields
GroupedRow(
title = stringResource(eventFormFieldLabel(field)),
position = positionOf(index, fields.size),
// The same icon the field carries in the new-event form.
leading = {
Icon(
imageVector = eventFormFieldIcon(field),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = checked,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
)
},
onClick = { viewModel.setFormFieldDefault(field, !checked) },
)
}
// Auto-focus the title on a new event (#10), on by default.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_autofocus_title),
summary = stringResource(R.string.settings_autofocus_title_hint),
position = Position.Alone,
leading = {
Icon(
imageVector = Icons.Default.Keyboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = state.autofocusEventTitle,
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
)
},
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
)
// Per-event colour on calendars that publish no colour set (some
// CalDAV); off by default, since it may not survive their next sync.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_color_unsupported),
summary = stringResource(R.string.settings_color_unsupported_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.allowColorOnUnsupportedCalendars,
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
)
},
onClick = {
viewModel.setAllowColorOnUnsupportedCalendars(
!state.allowColorOnUnsupportedCalendars,
)
},
)
}
}

View File

@@ -0,0 +1,400 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.datetime.LocalTime
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
* the pref is set either way; the OS permission is the real gate.
*/
@Composable
internal fun NotificationsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
val toggleReminders: (Boolean) -> Unit = { enabled ->
viewModel.setRemindersEnabled(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
if (needsPermission) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
onBack = onBack,
predictiveBack = true,
) {
GroupedRow(
title = stringResource(R.string.settings_reminders),
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
trailing = {
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
},
onClick = { toggleReminders(!state.remindersEnabled) },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
position = Position.Middle,
onClick = { showDefaultReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
position = Position.Middle,
onClick = { showAllDayReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_allday_reminder_time),
summary = stringResource(
R.string.settings_allday_reminder_time_hint,
settingsTimeOfDay(state.allDayReminderTimeMinutes),
),
position = Position.Bottom,
onClick = { showAllDayReminderTime = true },
)
// Reliability is a soft, optional battery-optimisation exemption via
// a system-settings deep-link, shown as live status.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Top,
trailing = if (batteryExempt) {
{ SelectedCheck() }
} else {
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
position = Position.Bottom,
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.
if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_calendar_reminders_title),
summary = stringResource(R.string.settings_calendar_reminders_hint),
position = Position.Alone,
trailing = {
Icon(
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
)
AnimatedVisibility(
visible = calendarSectionExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// Special-dates calendars own their reminders in their
// own section — link there instead.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName,
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
return@forEach
}
val expanded = calendar.id in expandedCalendars
GroupedRow(
title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = {
expandedCalendars = if (expanded) {
expandedCalendars - calendar.id
} else {
expandedCalendars + calendar.id
}
},
)
AnimatedVisibility(
visible = expanded,
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
)
}
}
}
}
}
}
}
if (showSnooze) {
SnoozeDurationPicker(
title = stringResource(R.string.settings_snooze_duration),
presets = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },
onDismiss = { showSnooze = false },
)
}
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
onDismiss = { showDefaultReminder = false },
)
}
if (showAllDayReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder_allday),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
onDismiss = { showAllDayReminder = false },
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
)
}
if (showAllDayReminderTime) {
TimePickerAlert(
initial = LocalTime(
state.allDayReminderTimeMinutes / 60,
state.allDayReminderTimeMinutes % 60,
),
onConfirm = {
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
showAllDayReminderTime = false
},
onDismiss = { showAllDayReminderTime = false },
)
}
overrideDialog?.let { target ->
val map = if (target.isAllDay) {
state.perCalendarAllDayReminderOverride
} else {
state.perCalendarReminderOverride
}
ReminderDefaultPicker(
title = stringResource(
if (target.isAllDay) {
R.string.settings_default_reminder_allday
} else {
R.string.settings_default_reminder
},
),
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
selected = map.reminderOverrideFor(target.calendarId),
allowInherit = true,
onSelect = {
if (target.isAllDay) {
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
} else {
viewModel.setCalendarReminderOverride(target.calendarId, it)
}
},
onDismiss = { overrideDialog = null },
leadTimeSummary = if (target.isAllDay) {
allDayFiringTimeSummary(state.allDayReminderTimeMinutes)
} else {
null
},
)
}
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (empty = none) as a picker choice for selection highlighting. */
private fun List<Int>.toReminderChoice(): ReminderOverride =
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun ReminderOverride.toMinutesList(): List<Int> =
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
/** Row summary for a calendar: its override, or the inherited global default. */
@Composable
private fun calendarOverrideSummary(
choice: ReminderOverride,
globalDefault: List<Int>,
): String = when (choice) {
ReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
}
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
@Composable
private fun snoozeDurationLabel(minutes: Int): String =
if (minutes % 60 == 0) {
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
} else {
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
}
/**
* Whether Calendula is exempt from battery optimisation, re-read on every
* `ON_RESUME` so a change made in system settings shows up at once.
*/
@Composable
private fun rememberBatteryOptimizationExempt(): Boolean {
val context = LocalContext.current
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
exempt = isIgnoringBatteryOptimizations(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
return exempt
}
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return power.isIgnoringBatteryOptimizations(context.packageName)
}
/**
* Open the direct `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog, falling back to
* the optimisation list if the OS refuses it.
*/
private fun openBatteryOptimizationSettings(context: Context) {
val direct = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${context.packageName}".toUri(),
)
if (runCatching { context.startActivity(direct) }.isFailure) {
runCatching {
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
}

View File

@@ -0,0 +1,184 @@
package de.jeanlucmakiola.calendula.ui.settings
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
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.
*/
@Composable
internal fun PastEventsPicker(
selected: PastEventDisplay,
onSelect: (PastEventDisplay) -> Unit,
onDismiss: () -> Unit,
) {
val options = PastEventDisplay.entries
val reduceMotion = rememberReduceMotion()
FullScreenPicker(
title = stringResource(R.string.settings_past_events),
onDismiss = onDismiss,
predictiveBack = true,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.height(PREVIEW_HEIGHT),
contentAlignment = Alignment.TopCenter,
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = "past-events-preview",
) { shown ->
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
PastEventsPreview(mode = shown)
}
}
}
PickerDescription(stringResource(R.string.settings_past_events_hint))
options.forEachIndexed { index, mode ->
val isSelected = mode == selected
GroupedRow(
title = stringResource(pastEventDisplayLabelRes(mode)),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(mode) },
)
}
}
}
/**
* 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.
*/
@Composable
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val titles = listOf(
stringResource(R.string.settings_past_events_sample_morning),
stringResource(R.string.settings_past_events_sample_midday),
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.
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 { } }
.clearAndSetSemantics { },
) {
AgendaDayHeader(date = today, today = today, onOpenDay = {})
visible.forEachIndexed { index, row ->
AgendaEventRow(
event = row.event,
day = today,
zone = zone,
position = positionOf(index, visible.size),
dimmed = mode == PastEventDisplay.DIM && row.hasPassed,
onClick = {},
)
}
}
}
/** One stand-in row plus whether it counts as finished. */
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.
*/
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
listOf(
sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true),
sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true),
sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false),
)
private fun sampleRow(
id: Long,
title: String,
day: LocalDate,
zone: TimeZone,
startHour: Int,
endHour: Int,
color: Int,
hasPassed: Boolean,
): SampleRow = SampleRow(
event = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = day.atTime(startHour, 0).toInstant(zone),
end = day.atTime(endHour, 0).toInstant(zone),
isAllDay = false,
color = color,
location = null,
),
hasPassed = hasPassed,
)
/** Room for the day header plus its three two-line rows, so hiding shortens the
* list inside a box that keeps its height instead of the page jumping a row. */
private val PREVIEW_HEIGHT = 288.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -0,0 +1,128 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.locale.currentLocale
/**
* Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each
* sub-screen owns whatever only it uses; anything two of them need lives here.
*/
/**
* 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.
*/
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
/** Leading circular icon chip, coloured by an M3 container/on-container pair. */
@Composable
internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
val scheme = MaterialTheme.colorScheme
val (background, iconColor) = when (accent) {
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
}
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(background),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(22.dp),
)
}
}
/**
* A small primary-coloured group label, matching the Calendars settings screen.
* Inset to [GroupedListInset] so it shares the cards' left margin.
*/
@Composable
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
@Composable
internal fun SettingsHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
internal fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
runCatching { context.startActivity(intent) }
}
/** Label for a global-default choice: empty → "None", else the lead times joined. */
@Composable
internal fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
}
/** Day-scale lead times for all-day defaults and contact special dates. */
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]). */
@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]).
*/
@Composable
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {
val summary = stringResource(
R.string.settings_allday_reminder_fires_at,
settingsTimeOfDay(allDayReminderTimeMinutes),
)
return { summary }
}

View File

@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -48,6 +49,8 @@ data class SettingsUiState(
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The size step the agenda widget draws its text at (#51). */
val widgetSize: WidgetSize = WidgetSize.SMALL,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */

View File

@@ -27,6 +27,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
@@ -38,9 +39,11 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
@@ -68,6 +71,7 @@ class SettingsViewModel @Inject constructor(
private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec,
private val launcherNameManager: LauncherNameManager,
private val reminderScanner: ReminderScanner,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -76,9 +80,8 @@ class SettingsViewModel @Inject constructor(
/**
* Writable calendars that are switched on — the only ones that take a
* 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).
* per-calendar reminder override, since a switched-off one plans no
* reminders at all (#75).
*/
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
@@ -160,8 +163,9 @@ class SettingsViewModel @Inject constructor(
prefs.quickSwitchConfig,
prefs.drawerViewOrder,
prefs.monthViewStyle,
) { quickSwitch, drawer, monthStyle ->
ViewCustomization(quickSwitch, drawer, monthStyle)
prefs.widgetSize,
) { quickSwitch, drawer, monthStyle, widgetSize ->
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
@@ -184,6 +188,7 @@ class SettingsViewModel @Inject constructor(
quickSwitchConfig = misc.viewCustomization.quickSwitch,
drawerViewOrder = misc.viewCustomization.drawerOrder,
monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -219,17 +224,10 @@ class SettingsViewModel @Inject constructor(
)
/**
* 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.
* 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.
*/
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
@@ -245,10 +243,8 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// 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.
// Serialises widget redraws: concurrent updateAll calls coalesce around a
// stale read and can leave the widget on an intermediate value.
private val widgetRefreshMutex = Mutex()
private data class ReminderDefaults(
@@ -298,6 +294,7 @@ class SettingsViewModel @Inject constructor(
val quickSwitch: QuickSwitchConfig,
val drawerOrder: List<CalendarView>,
val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
@@ -400,9 +397,7 @@ class SettingsViewModel @Inject constructor(
fun setSoftenColors(enabled: Boolean) {
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener, so a
// change has to redraw them too (they read the flag in their data
// preamble, like is24Hour).
// Both widgets paint event colours through the same softener.
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
@@ -428,9 +423,8 @@ 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 (same
// file, same "custom" token) still breaks font-settings equality
// and refreshes the resolved typeface.
// Bump the stamp so replacing an already-active custom font
// still breaks equality and refreshes the resolved typeface.
prefs.bumpCustomFontStamp(role)
setFont(role, FONT_CUSTOM_TOKEN)
} else {
@@ -459,10 +453,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
prefs.setAgendaWidgetRange(range)
widgetRefreshMutex.withLock {
// 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).
// Push into each instance's Glance state and recompose:
// updateAll alone won't 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() }
@@ -476,12 +468,28 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
/**
* 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).
*/
fun setWidgetSize(size: WidgetSize) {
viewModelScope.launch {
prefs.setWidgetSize(size)
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// The agenda widget reads this reactively, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
// Same Glance-state path as setPastEventDisplay.
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
@@ -509,10 +517,9 @@ class SettingsViewModel @Inject constructor(
}
/**
* 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.
* 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.
*/
fun setLauncherName(name: LauncherName) {
_launcherName.value = name
@@ -527,9 +534,7 @@ class SettingsViewModel @Inject constructor(
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// 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).
// Same Glance-state path as setAgendaWidgetRange.
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
@@ -553,12 +558,10 @@ class SettingsViewModel @Inject constructor(
}
/**
* 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.
* 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.
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
@@ -582,8 +585,15 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}
/**
* A scan follows the write: switching reminders off cancels the scan alarm,
* so switching them back on has to arm a new one (#75).
*/
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
viewModelScope.launch {
prefs.setRemindersEnabled(enabled)
reminderScanner.scan()
}
}
fun setAutofocusEventTitle(enabled: Boolean) {
@@ -598,8 +608,12 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
}
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
viewModelScope.launch {
prefs.setAllDayReminderTimeMinutes(minutesOfDay)
reminderScanner.scan()
}
}
fun setSnoozeMinutes(minutes: Int) {

View File

@@ -0,0 +1,318 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.reminders.ReminderOverride
// ---------------------------------------------------------------------------
// Contact special dates (issue #15)
// ---------------------------------------------------------------------------
@Composable
internal fun SpecialDatesScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
val settings by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
// READ_CONTACTS is requested only here, on enable — never at startup.
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
}
}
val requestOrEnable: () -> Unit = {
if (context.hasContactsPermission()) {
viewModel.setSpecialDatesEnabled(true)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
var confirmDisableAll by remember { mutableStateOf(false) }
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_special_dates),
onBack = onBack,
predictiveBack = true,
) {
// Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.settings_special_dates_paused_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.settings_special_dates_paused_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
) { Text(stringResource(R.string.settings_special_dates_grant)) }
}
}
Spacer(Modifier.height(16.dp))
}
GroupedRow(
title = stringResource(R.string.settings_special_dates_enable),
summary = stringResource(R.string.settings_special_dates_enable_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.enabled,
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
)
},
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
)
if (state.enabled) {
SpecialDateType.entries.forEachIndexed { index, type ->
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
val on = type in state.types
GroupedRow(
title = stringResource(specialDateTypeLabel(type)),
position = if (on) Position.Top else Position.Alone,
trailing = {
Switch(
checked = on,
onCheckedChange = { want ->
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
else confirmDisableType = type
},
)
},
onClick = {
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
},
)
if (on) {
GroupedRow(
title = stringResource(R.string.settings_special_dates_template),
summary = state.titleTemplates[type].orEmpty(),
position = Position.Middle,
onClick = { editTemplate = type },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_reminders),
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
position = Position.Bottom,
onClick = { reminderPickerType = type },
)
}
}
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_special_dates_show_year),
summary = stringResource(R.string.settings_special_dates_show_year_hint),
position = Position.Top,
trailing = {
Switch(
checked = state.showYear,
onCheckedChange = viewModel::setSpecialDatesShowYear,
)
},
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_sync_now),
summary = specialDatesLastRunLabel(context, state.lastRun),
position = Position.Bottom,
onClick = viewModel::syncSpecialDatesNow,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.settings_special_dates_calendar_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
if (confirmDisableAll) {
SpecialDatesDisableDialog(
message = stringResource(R.string.settings_special_dates_disable_all_message),
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
onDismiss = { confirmDisableAll = false },
)
}
confirmDisableType?.let { type ->
SpecialDatesDisableDialog(
message = stringResource(
R.string.settings_special_dates_disable_type_message,
stringResource(specialDateTypeLabel(type)),
),
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
onDismiss = { confirmDisableType = null },
)
}
editTemplate?.let { type ->
SpecialDatesTemplateDialog(
initial = state.titleTemplates[type].orEmpty(),
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
onDismiss = { editTemplate = null },
)
}
reminderPickerType?.let { type ->
ReminderDefaultPicker(
title = stringResource(R.string.settings_special_dates_reminders),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.reminderChoices[type] ?: ReminderOverride.None,
// Managed calendars own their reminders outright — no "inherit global".
allowInherit = false,
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
onDismiss = { reminderPickerType = null },
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
)
}
}
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
@Composable
private fun SpecialDatesDisableDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.settings_special_dates_disable_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun SpecialDatesTemplateDialog(
initial: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var text by rememberSaveable { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_special_dates_template)) },
text = {
Column {
// The dialog convention — see DialogControls.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = text,
onValueChange = { text = it },
placeholder = stringResource(R.string.settings_special_dates_template),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.settings_special_dates_template_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(text) },
enabled = text.isNotBlank(),
) { Text(stringResource(R.string.dialog_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
}
@Composable
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
if (lastRun <= 0L) {
stringResource(R.string.settings_special_dates_never_synced)
} else {
stringResource(
R.string.settings_special_dates_last_synced,
android.text.format.DateUtils.getRelativeTimeSpanString(
lastRun,
System.currentTimeMillis(),
android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString(),
)
}

View File

@@ -0,0 +1,416 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.text.format.DateFormat
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.ReorderableColumn
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.DayOfWeek
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.
*/
@Composable
internal fun ViewsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showMonthStyle by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showTimeFormat by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
onBack = onBack,
predictiveBack = true,
) {
val config = state.quickSwitchConfig
// What holds for every view, above the per-view groups.
SectionHeader(stringResource(R.string.settings_views_all_header))
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Top,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart),
position = Position.Middle,
onClick = { showWeekStart = true },
)
GroupedRow(
title = stringResource(R.string.settings_time_format),
summary = timeFormatLabel(state.timeFormat),
position = Position.Middle,
onClick = { showTimeFormat = true },
)
GroupedRow(
title = stringResource(R.string.settings_today_toolbar),
summary = stringResource(R.string.settings_today_toolbar_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.todayButtonInToolbar,
onCheckedChange = viewModel::setTodayButtonInToolbar,
)
},
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
)
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dimCompletedEvents,
onCheckedChange = viewModel::setDimCompletedEvents,
)
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_month_header))
GroupedRow(
title = stringResource(R.string.settings_month_view_style),
summary = stringResource(state.monthViewStyle.labelRes),
position = Position.Top,
onClick = { showMonthStyle = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_numbers),
summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
)
},
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
)
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_week_day_header))
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Alone,
trailing = {
Switch(
checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourLines,
)
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
)
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_agenda_header))
GroupedRow(
title = stringResource(R.string.settings_agenda_range),
summary = agendaRangeLabel(state.agendaScreenRange),
position = Position.Top,
onClick = { showAgendaScreenRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = pastEventDisplayLabel(state.pastEventDisplay),
position = Position.Middle,
onClick = { showPastEvents = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_show_today),
summary = stringResource(R.string.settings_agenda_show_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
)
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.agendaShowRangeBar,
onCheckedChange = viewModel::setAgendaShowRangeBar,
)
},
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
)
Spacer(Modifier.height(24.dp))
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
onReorder = { viewModel.setQuickSwitchOrder(it) },
) { view, position, dragHandle, isDragging ->
val checked = view in config.enabled
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
dimmed = !checked,
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},
)
}
Spacer(Modifier.height(24.dp))
SectionHeader(stringResource(R.string.settings_drawer_order_header))
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
Spacer(Modifier.height(8.dp))
ReorderableColumn(
items = state.drawerViewOrder,
keyOf = { it },
onReorder = { viewModel.setDrawerViewOrder(it) },
) { view, position, dragHandle, isDragging ->
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
)
}
}
if (showMonthStyle) {
MonthViewStylePicker(
selected = state.monthViewStyle,
// The preview is a real grid, so it uses the real week start too.
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setMonthViewStyle,
onDismiss = { showMonthStyle = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) },
predictiveBack = true,
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
leading = {
Icon(
imageVector = it.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
if (showWeekStart) {
WeekStartPicker(
selected = state.weekStart,
// The preview is a real grid, so it uses the user's own month style.
monthStyle = state.monthViewStyle,
options = WEEK_START_OPTIONS,
onSelect = viewModel::setWeekStart,
onDismiss = { showWeekStart = false },
)
}
if (showTimeFormat) {
val locale = currentLocale()
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
OptionPicker(
title = stringResource(R.string.settings_time_format),
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
predictiveBack = true,
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.
summary = { pref ->
val sample = formatTimeOfDay(
hour = SAMPLE_HOUR,
minute = SAMPLE_MINUTE,
is24Hour = when (pref) {
TimeFormatPref.AUTO -> systemIs24Hour
TimeFormatPref.TWELVE_HOUR -> false
TimeFormatPref.TWENTY_FOUR_HOUR -> true
},
locale = locale,
)
if (pref == TimeFormatPref.AUTO) {
stringResource(R.string.settings_time_format_auto_summary, sample)
} else {
sample
}
},
onSelect = viewModel::setTimeFormat,
onDismiss = { showTimeFormat = false },
)
}
if (showPastEvents) {
PastEventsPicker(
selected = state.pastEventDisplay,
onSelect = viewModel::setPastEventDisplay,
onDismiss = { showPastEvents = false },
)
}
if (showAgendaScreenRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.settings_agenda_range_hint),
selected = state.agendaScreenRange,
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setAgendaScreenRange,
onDismiss = { showAgendaScreenRange = false },
)
}
}
/** Sample time the format options render: afternoon, where 12h and 24h differ. */
private const val SAMPLE_HOUR = 14
private const val SAMPLE_MINUTE = 30
/** One reorderable view row: the view's icon and name, an optional [trailing]
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
@Composable
private fun ViewRow(
view: CalendarView,
position: Position,
isDragging: Boolean,
dragHandle: Modifier,
dimmed: Boolean = false,
trailing: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = stringResource(view.labelRes),
position = position,
dimmed = dimmed,
minHeight = ReorderableRowHeight,
// The reorderable column owns the inter-row spacing (uniform pitch).
gapBelow = false,
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
leading = {
Icon(
imageVector = view.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Row(verticalAlignment = Alignment.CenterVertically) {
trailing?.invoke()
if (trailing != null) Spacer(Modifier.width(8.dp))
Box(
modifier = dragHandle.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.reorder_drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
private val WEEK_START_OPTIONS: List<WeekStartPref> =
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
@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.
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}
@Composable
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
when (pref) {
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
},
)
@Composable
private fun pastEventDisplayLabel(mode: PastEventDisplay): String =
stringResource(pastEventDisplayLabelRes(mode))
/** Shared with [PastEventsPicker], which needs the id rather than the string. */
@StringRes
internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) {
PastEventDisplay.SHOW -> R.string.settings_past_events_show
PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
}

View File

@@ -0,0 +1,110 @@
package de.jeanlucmakiola.calendula.ui.settings
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.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
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.
*
* A preview picker, so selecting applies at once and keeps the picker open.
*/
@Composable
internal fun WeekStartPicker(
selected: WeekStartPref,
monthStyle: MonthViewStyle,
options: List<WeekStartPref>,
onSelect: (WeekStartPref) -> Unit,
onDismiss: () -> Unit,
) {
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val resolved = selected.resolveFirstDay(locale)
FullScreenPicker(
title = stringResource(R.string.settings_week_start),
onDismiss = onDismiss,
predictiveBack = true,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.height(PREVIEW_HEIGHT),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = resolved,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = "week-start-preview",
) { day ->
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
MonthStylePreview(
style = monthStyle,
weekStart = day,
height = PREVIEW_HEIGHT,
)
}
}
}
PickerDescription(stringResource(R.string.settings_week_start_hint))
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = weekStartLabel(option),
summary = if (option == WeekStartPref.Auto) {
stringResource(
R.string.settings_week_start_auto_summary,
weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))),
)
} else {
null
},
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(option) },
)
}
}
}
/** Short enough to leave the first options on screen under it. */
private val PREVIEW_HEIGHT = 200.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -0,0 +1,152 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.app.StatusBarManager
import android.content.ComponentName
import android.content.Context
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
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].
*/
@Composable
internal fun WidgetsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
) {
SettingsHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
position = Position.Top,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_widget_size),
summary = widgetSizeLabel(state.widgetSize),
position = Position.Bottom,
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.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Spacer(Modifier.height(24.dp))
QuickSettingsTileRow()
}
}
if (showAgendaWidgetRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_widget_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = state.agendaWidgetRange,
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setAgendaWidgetRange,
onDismiss = { showAgendaWidgetRange = false },
)
}
if (showWidgetSize) {
OptionPicker(
title = stringResource(R.string.settings_widget_size),
header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
predictiveBack = true,
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.
summary = { widgetSizeSummary(it) },
onSelect = viewModel::setWidgetSize,
onDismiss = { showWidgetSize = false },
)
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
private fun QuickSettingsTileRow() {
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = Position.Alone,
onClick = { requestAddQsTile(context) },
)
}
/**
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
* shows its own confirmation dialog and handles the already-added case, so no
* result handling is needed here.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun requestAddQsTile(context: Context) {
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
statusBar.requestAddTileService(
ComponentName(context, NewEventTileService::class.java),
context.getString(R.string.qs_tile_new_event_label),
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* 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.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)

View File

@@ -74,6 +74,8 @@ sealed interface AgendaWidgetData {
* from Glance state before an instance has its own state set.
*/
val savedShowToday: Boolean,
/** Saved widget size step (#103) — the fallback before Glance state is set. */
val savedWidgetSize: WidgetSize,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
@@ -145,6 +147,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
savedWidgetSize = prefs.widgetSize.first(),
now = Clock.System.now(),
)
}

View File

@@ -1,70 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
/**
* Size tiers a widget scales its typography and metrics across (#51).
*
* Shared by every Glance widget so the bucketing rule can't drift between them:
* each widget keeps its own metrics table, but they all agree on *when* a widget
* counts as compact, regular, large or extra-large. Both widgets already declare
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
* size via `LocalSize.current` and passes it to [scaleFor].
*
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
* covered by plain JVM tests.
*/
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
/**
* Chrome a widget spends before its first content row: outer vertical padding
* plus a header row and its spacer. Subtracted from the raw height so the height
* thresholds below talk about *usable* space rather than gross widget height.
*/
private val CHROME_HEIGHT = 60.dp
/**
* Buckets a live widget size into a [WidgetScale] by **width**.
*
* Width is the right axis: it governs how much of a title fits on a row, so it's
* what should drive type size. Height only decides how many rows are visible — a
* tall, narrow widget wants *more events*, not bigger text — so it never raises
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
* stepped back down so it can't keep oversized type in a sliver of space.
*
* The width thresholds are spread across the range a phone can actually produce
* (~180dp up to roughly the screen width) rather than over a theoretical range,
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
* surfaces — tablets, foldables, landscape — where the extra size reads well.
*
* The height cap is deliberately generous: it exists to catch a widget squashed
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
* the usual three cells tall (~270dp) must still reach the tier its width earned
* — that is exactly the resize #51 reports, and an aggressive cap would make the
* whole feature a no-op for it.
*/
internal fun scaleFor(size: DpSize): WidgetScale {
val byWidth = when {
size.width < 260.dp -> WidgetScale.COMPACT
size.width < 330.dp -> WidgetScale.REGULAR
size.width < 420.dp -> WidgetScale.LARGE
else -> WidgetScale.XLARGE
}
// Height can only ever pull the tier *down*, never push it up: a squashed
// widget would otherwise keep the big type its width earned and look absurd
// in the little space left. Keeping this a cap (rather than a second scaling
// axis) is what preserves "tall and narrow shows more events, not bigger
// text". Thresholds are usable height — roughly one, two and three rows of
// breathing room once the header is paid for.
val usable = size.height - CHROME_HEIGHT
val heightCap = when {
usable < 70.dp -> WidgetScale.COMPACT
usable < 130.dp -> WidgetScale.REGULAR
usable < 200.dp -> WidgetScale.LARGE
else -> WidgetScale.XLARGE
}
return minOf(byWidth, heightCap)
}

View File

@@ -0,0 +1,10 @@
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).
*
* [SMALL] is the default and reproduces the widget's original constants.
*/
enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }

View File

@@ -4,14 +4,12 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.widget.WidgetScale
import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Horizontal layout constants for an agenda event row. These don't scale with the
* tier — 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.
* 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.
*/
internal val ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp
@@ -21,23 +19,9 @@ internal val STRIPE_GAP = 10.dp
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
/**
* The width band a *default* agenda placement can land in, per
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
* launcher cell grids vary, so the whole band — not one measured point — has to
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
* to hold. A test pins that.
*
* If the provider's `targetCellWidth` ever changes, this band and the first
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
* revisited together.
*/
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
/**
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
* grow (the horizontal row constants above, corner radii) stay constants rather
* than routing through here.
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
* shouldn't grow (the horizontal row constants above, corner radii) stay
* constants rather than routing through here.
*/
internal data class AgendaMetrics(
val title: TextUnit, // header "Upcoming"
@@ -53,37 +37,24 @@ 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] 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 tier's value exactly.
* 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.
*/
fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
}
/*
* 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.
* Type sizes are anchored to the Material 3 type scale, with two deviations:
*
* Two documented deviations:
*
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
* COMPACT reproduces the widget's original constants verbatim so a
* default-sized widget 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 widget tiers. Where a role would force a
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
* mark it. The endpoints stay on real roles.
* ‡ 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.
*/
private val COMPACT_METRICS = AgendaMetrics(
private val SMALL_METRICS = AgendaMetrics(
title = 16.sp, // M3 Title Medium
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
eventTitle = 14.sp, // M3 Body Medium
@@ -97,7 +68,7 @@ private val COMPACT_METRICS = AgendaMetrics(
dayHeaderTopPad = 10.dp,
)
private val REGULAR_METRICS = AgendaMetrics(
private val MEDIUM_METRICS = AgendaMetrics(
title = 18.sp, // †
dayHeader = 14.sp, // M3 Title Small
eventTitle = 16.sp, // M3 Body Large
@@ -125,7 +96,7 @@ private val LARGE_METRICS = AgendaMetrics(
dayHeaderTopPad = 12.dp,
)
private val XLARGE_METRICS = AgendaMetrics(
private val EXTRA_LARGE_METRICS = AgendaMetrics(
title = 22.sp, // M3 Title Large
dayHeader = 18.sp, // †
eventTitle = 20.sp, // †
@@ -139,12 +110,12 @@ private val XLARGE_METRICS = AgendaMetrics(
dayHeaderTopPad = 14.dp,
)
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
private val AGENDA_METRICS = listOf(
COMPACT_METRICS,
REGULAR_METRICS,
SMALL_METRICS,
MEDIUM_METRICS,
LARGE_METRICS,
XLARGE_METRICS,
EXTRA_LARGE_METRICS,
)
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]
internal fun metricsFor(size: WidgetSize): AgendaMetrics = AGENDA_METRICS[size.ordinal]

View File

@@ -13,7 +13,6 @@ import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
@@ -62,7 +61,7 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.scaleFor
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
@@ -105,22 +104,23 @@ internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display
*/
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
/**
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
* in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
* setting reflects on the live widget without depending on the `provideGlance`
* preamble re-running.
*/
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact so the composition sees the widget's live size and can scale type/rows
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
// same.
//
// Note Exact still asks Glance for one RemoteViews per host size (typically
// portrait + landscape) where the old SizeMode.Single produced exactly one —
// so the serialized payload roughly doubles. That is why the row list below is
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
// = 365) could otherwise push the RemoteViews past the binder transaction
// limit and the host would just show "Problem loading widget".
override val sizeMode = SizeMode.Exact
// 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.
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData()
@@ -160,12 +160,16 @@ private sealed interface AgendaRow {
@Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
// Read reactively from per-instance Glance state, falling back to the saved
// pref for a freshly placed widget (#103, #51).
// 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
val size = currentState(AGENDA_SIZE_KEY)
?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
?: savedSize
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
val metrics = metricsFor(size).scaledForFont(fontScale)
Column(
modifier = GlanceModifier
.fillMaxSize()

View File

@@ -155,6 +155,9 @@
<!-- Shown in place of the live rule read-out when an amount field holds a
value outside 1999 (i.e. 0), so the greyed-out OK button has a reason. -->
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
<!-- %1$s is a list of the next few dates, e.g. "30 Jul, 6 Aug and 13 Aug". -->
<string name="event_edit_recurrence_next">Next: %1$s</string>
<string name="event_edit_recurrence_next_none">This rule never repeats</string>
<string name="event_edit_recurrence_ends">Ends</string>
<string name="event_edit_recurrence_end_never">Never</string>
<string name="event_edit_recurrence_end_until">On a date</string>
@@ -198,6 +201,12 @@
<string name="event_availability_free">Free</string>
<string name="event_access_private">Private</string>
<string name="event_access_confidential">Confidential</string>
<!-- Second lines in the visibility picker: what each level means to people
the calendar is shared with. -->
<string name="event_access_default_summary">Whatever this calendar normally does</string>
<string name="event_access_public_summary">Everyone with access sees the full details</string>
<string name="event_access_private_summary">Others see only that you\'re busy</string>
<string name="event_access_confidential_summary">Marked confidential; what that means is up to the calendar account</string>
<string name="event_attendee_organizer">Organizer</string>
<string name="event_attendee_optional">Optional</string>
<string name="event_attendee_resource">Resource</string>
@@ -315,6 +324,9 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_hint">Whether the app is light or dark. The choice applies immediately.</string>
<!-- %1$s is the theme the system currently resolves to, e.g. "Dark". -->
<string name="settings_theme_system_summary">Currently %1$s</string>
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
@@ -329,8 +341,14 @@
<string name="settings_font_choose_file">Choose file…</string>
<string name="settings_font_custom_selected">Custom font</string>
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<!-- Specimen line shown in the headings-font picker, set in the chosen face. -->
<string name="settings_font_specimen_heading">Thursday, 14 May</string>
<!-- Specimen paragraph shown in the body-font picker, set in the chosen face. -->
<string name="settings_font_specimen_body">Team review at 10:00, then lunch with Robin at the café on Marktplatz.</string>
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<!-- %1$s is the weekday the automatic setting currently resolves to, e.g. "Monday". -->
<string name="settings_week_start_auto_summary">Currently %1$s</string>
<string name="settings_week_numbers">Week numbers</string>
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
<string name="settings_today_toolbar">Today button in toolbar</string>
@@ -341,6 +359,8 @@
<string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
<string name="settings_time_format_24h">24-hour (14:00)</string>
<!-- %1$s is a sample time written the way the system currently writes it. -->
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_dim_completed">Dim completed events</string>
@@ -349,11 +369,24 @@
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</string>
<string name="settings_past_events_hide">Hide</string>
<!-- Stand-in event titles in the past-events preview. Short, everyday entries. -->
<string name="settings_past_events_sample_morning">Team review</string>
<string name="settings_past_events_sample_midday">Lunch with Robin</string>
<string name="settings_past_events_sample_evening">Choir practice</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_widget_size">Agenda widget size</string>
<string name="settings_widget_size_hint">How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it.</string>
<string name="settings_widget_size_small">Small</string>
<string name="settings_widget_size_medium">Medium</string>
<string name="settings_widget_size_large">Large</string>
<string name="settings_widget_size_extra_large">Extra large</string>
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
<string name="settings_widget_size_summary">Event text %1$d%%</string>
<string name="settings_agenda_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
<string name="settings_agenda_range_bar">Range bar</string>
@@ -404,6 +437,8 @@
<string name="settings_default_reminder_allday">All-day events</string>
<string name="settings_allday_reminder_time">All-day reminder time</string>
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
<!-- Second line on an all-day lead-time row. %1$s is a clock time, e.g. "09:00". -->
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
<string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</string>
@@ -423,12 +458,39 @@
<string name="settings_language_auto">System default</string>
<string name="settings_translate">Help translate</string>
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
<!-- Hub group headers (#69) -->
<string name="settings_group_look">Look &amp; behaviour</string>
<string name="settings_group_data">Data</string>
<string name="settings_group_app">App</string>
<!-- Group of reference links at the foot of the settings hub. -->
<string name="settings_group_about">About</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_views_subtitle">Month layout, quick-switch button, menu order</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_appearance_subtitle">Theme, colours, fonts</string>
<string name="settings_views_subtitle">Default view, layout, order</string>
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
<string name="settings_notifications_subtitle">Reminders and delivery</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
<!-- Widgets &amp; tiles (#69) -->
<string name="settings_section_widgets">Widgets &amp; tiles</string>
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
<!-- Backup &amp; restore (#69) — the rows themselves reuse the calendars_* strings -->
<string name="settings_section_backup">Backup &amp; restore</string>
<!-- Views sub-headers (#69) -->
<string name="settings_views_all_header">All views</string>
<string name="settings_week_day_header">Week &amp; day</string>
<!-- Picker descriptions (#69) -->
<string name="settings_default_view_hint">The view Calendula opens on when you start it.</string>
<string name="settings_week_start_hint">The day every week begins on, in all views and widgets.</string>
<string name="settings_time_format_hint">How times are written throughout the app. Automatic follows your system setting.</string>
<string name="settings_past_events_hint">What the agenda does with events that have already ended.</string>
<string name="settings_dynamic_color_summary">Take the app\'s colours from your wallpaper.</string>
<!-- Contact special dates (issue #15) -->
<string name="settings_section_special_dates">Contact special dates</string>
@@ -488,6 +550,8 @@
<string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage in app</string>
<!-- Account header when two accounts share a name: %1$s is the account, %2$s the app it comes from. -->
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="calendars_account_menu_a11y">More options for %1$s</string>
<string name="calendars_enable_all">Enable all</string>
<string name="calendars_disable_all">Disable all</string>

View File

@@ -54,7 +54,8 @@ class AllDayReminderEncodingTest {
for (time in listOf(0, nineAm, 20 * 60)) {
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
assertThat(fromProviderAllDayMinutes(raw, date, berlin)).isEqualTo(semantic)
assertThat(fromProviderAllDayMinutes(raw, date, berlin, time))
.isEqualTo(semantic)
}
}
}
@@ -64,9 +65,31 @@ class AllDayReminderEncodingTest {
fun `pre-feature rows (raw multiple of 1440) still decode to whole days`() {
// Reminders written before this feature stored raw N*1440 (fired at UTC
// midnight). They must still read back as "N days before".
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin)).isEqualTo(2_880)
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin, nineAm)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin, nineAm)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin, nineAm)).isEqualTo(2_880)
}
@Test
fun `an offset that encodes to a multiple still decodes to its own lead time`() {
// New York at 20:00 is the collision: "1 day before" encodes to a plain 0,
// which read at face value would say "at time of event". The screen has to
// show what the reminder does, which is fire the evening before.
val newYork = ZoneId.of("America/New_York")
val eightPm = 20 * 60
val raw = toProviderAllDayMinutes(1_440, summer, newYork, eightPm)
assertThat(raw).isEqualTo(0)
assertThat(fromProviderAllDayMinutes(raw, summer, newYork, eightPm)).isEqualTo(1_440)
}
@Test
fun `a foreign multiple west of UTC keeps its face-value lead time`() {
// No encoded hour in this row, and its instant lands on the evening two
// days out in New York — the local-date reading would say "2 days before".
val newYork = ZoneId.of("America/New_York")
assertThat(fromProviderAllDayMinutes(1_440, summer, newYork, nineAm)).isEqualTo(1_440)
}
@Test
@@ -74,8 +97,8 @@ class AllDayReminderEncodingTest {
val atNine = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
val atEight = toProviderAllDayMinutes(1_440, summer, berlin, 8 * 60)
assertThat(atNine).isNotEqualTo(atEight)
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin, nineAm)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin, nineAm)).isEqualTo(1_440)
}
@Test

View File

@@ -408,11 +408,9 @@ 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 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.
// 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.
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {

View File

@@ -79,7 +79,8 @@ class EventDetailMapperTest {
private fun MapColumnReader.toDetail(
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
reminders: List<Reminder> = emptyList(),
) = toEventDetailCore(attendees, reminders)
allDayReminderTimeMinutes: Int = 9 * 60,
) = toEventDetailCore(attendees, reminders, allDayReminderTimeMinutes)
@Test
fun `happy path detail maps all fields and embeds matching EventInstance`() {

View File

@@ -72,7 +72,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {

View File

@@ -0,0 +1,194 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class RecurrenceOccurrencesTest {
private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day)
@Test
fun `daily rule starts at the event's own date`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `interval multiplies the period`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 2),
date(2026, 8, 5),
).inOrder()
}
@Test
fun `weekly without weekday picks repeats the start's weekday`() {
// 30 Jul 2026 is a Thursday.
val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 6),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `weekly with picks fires on every chosen day, in weekday order`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
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.
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),
).inOrder()
}
@Test
fun `weekly interval skips whole weeks, counted from Monday`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
// Start week (27 Jul2 Aug) contributes only its Thursday; the next block
// is two weeks on, so 10 Aug and 13 Aug — not 3 Aug.
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 10),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `monthly skips months without the start day rather than clamping`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(date(2026, 1, 31), limit = 4)
// February, April and June have no 31st — the rule passes them by.
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
date(2026, 7, 31),
).inOrder()
}
@Test
fun `yearly on 29 February only lands in leap years`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly)
.upcomingOccurrences(date(2024, 2, 29), limit = 3)
assertThat(occurrences).containsExactly(
date(2024, 2, 29),
date(2028, 2, 29),
date(2032, 2, 29),
).inOrder()
}
@Test
fun `count limits the series and skipped periods don't consume one`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Monthly,
end = RecurrenceEnd.Count(3),
).upcomingOccurrences(date(2026, 1, 31), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
).inOrder()
}
@Test
fun `until is inclusive of its own date`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 8, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `an until before the start yields nothing instead of spinning`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 7, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).isEmpty()
}
@Test
fun `a run inside one year needs no year, one that leaves it does`() {
val start = date(2026, 7, 30)
val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(withinYear, start)).isFalse()
// Yearly: same day and month every time, so the year is the only thing
// telling the three dates apart.
val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(yearly, start)).isTrue()
// Monthly rolling past December.
val newYearStart = date(2026, 11, 30)
val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(newYearStart, limit = 3)
assertThat(overflowing).containsExactly(
date(2026, 11, 30),
date(2026, 12, 30),
date(2027, 1, 30),
).inOrder()
assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue()
}
@Test
fun `limit and count are both respected, whichever is smaller`() {
val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2))
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty()
}
}

View File

@@ -184,8 +184,58 @@ class ReminderPlanTest {
@Test
fun `a zero-day row fires on the event's own date`() {
assertThat(allDayLeadDays(rawMinutes = 0, beginMillis = allDayBegin("2026-07-15"), zone = berlin))
.isEqualTo(0L)
assertThat(
allDayLeadDays(
rawMinutes = 0,
startDate = LocalDate.parse("2026-07-15"),
zone = berlin,
allDayTimeMinutes = nineAm,
),
).isEqualTo(0L)
}
@Test
fun `our own row that encodes to a multiple is not read at face value`() {
// New York with the all-day hour at 20:00: "1 day before" encodes to a
// plain 0, because 20:00 EDT *is* UTC midnight. Read at face value it
// fired at 20:00 on the day of the event instead of the evening before.
val newYork = ZoneId.of("America/New_York")
val eightPm = 20 * 60
val row = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
assertThat(row % 1_440).isEqualTo(0)
val alarm = plan(
instances = listOf(
instance(1L, beginMillis = allDayBegin("2026-07-15"), isAllDay = true),
),
minutesByEvent = mapOf(1L to listOf(row)),
zone = newYork,
allDayTimeMinutes = eightPm,
).single().alarmMillis
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(20, 0))
}
@Test
fun `such a row keeps its lead time across the DST boundary that wrote it`() {
// Same row, sampled in EDT, on a winter occurrence: its instant lands an
// hour off the named hour, which still has to read as "one of ours".
val newYork = ZoneId.of("America/New_York")
val eightPm = 20 * 60
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
val alarm = plan(
instances = listOf(
instance(1L, beginMillis = allDayBegin("2027-01-15"), isAllDay = true),
),
minutesByEvent = mapOf(1L to listOf(summerRow)),
zone = newYork,
allDayTimeMinutes = eightPm,
).single().alarmMillis
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2027-01-14").atTime(20, 0))
}
@Test
@@ -437,6 +487,17 @@ class ReminderPlanTest {
.isEqualTo(7 * day + 14 * day)
}
@Test
fun `the query horizon is capped against an outlier row`() {
// The longest offset comes from any row in the provider — an imported
// TRIGGER:-P100W would otherwise expand every series over two years, on
// every scan.
val hundredWeeks = 100 * 7 * 24 * 60
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = hundredWeeks))
.isEqualTo(7 * day + MAX_REMINDER_LEAD_MILLIS)
}
@Test
fun `a first-ever scan starts from now, not from the epoch`() {
// Otherwise an install — or the upgrade onto in-house delivery — treats

View File

@@ -28,7 +28,7 @@ class FilterGroupingTest {
cal(3, "Shared", "team@dav"),
)
val groups = groupByAccount(calendars, hidden = emptySet())
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
assertThat(groups[0].calendars.map { it.displayName })
@@ -43,7 +43,7 @@ class FilterGroupingTest {
cal(2, "Work", "alice@dav"),
)
val groups = groupByAccount(calendars, hidden = setOf(2L))
val groups = groupCalendarsForFilter(calendars, hidden = setOf(2L))
val rows = groups.single().calendars.associateBy { it.id }
assertThat(rows.getValue(1L).visible).isTrue()
@@ -52,10 +52,52 @@ class FilterGroupingTest {
@Test
fun `blank account name falls back to type`() {
val groups = groupByAccount(
val groups = groupCalendarsForFilter(
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
hidden = emptySet(),
)
assertThat(groups.single().account).isEqualTo("LOCAL")
}
/** #77: same address, two apps — two accounts, and they must read as two. */
@Test
fun `one name under two account types stays two groups`() {
val calendars = listOf(
cal(1, "Personal", "me@example.com", type = "com.google"),
cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"),
)
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
assertThat(groups).hasSize(2)
assertThat(groups.map { it.accountType })
.containsExactly("com.google", "bitfire.at.davdroid").inOrder()
assertThat(groups.map { it.calendars.single().id }).containsExactly(1L, 2L).inOrder()
assertThat(groups.all { it.ambiguous }).isTrue()
}
@Test
fun `one name under one type is not ambiguous`() {
val groups = groupCalendarsForFilter(
listOf(cal(1, "Personal", "me@example.com")),
hidden = emptySet(),
)
assertThat(groups.single().ambiguous).isFalse()
}
/** Two different names from the same app are still two plain groups. */
@Test
fun `different names under one type are not ambiguous`() {
val groups = groupCalendarsForFilter(
listOf(
cal(1, "Personal", "alice@example.com"),
cal(2, "Shared", "bob@example.com"),
),
hidden = emptySet(),
)
assertThat(groups).hasSize(2)
assertThat(groups.none { it.ambiguous }).isTrue()
}
}

View File

@@ -122,6 +122,42 @@ class LaneEventsTest {
.containsNoneIn(seated)
}
@Test
fun `overflow is exactly what seating left behind`() {
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
val week = rowOfJuly6(events)
val day = LocalDate(2026, 7, 6)
val seated = week.laneEvents(col = 0, day = day, laneCap = 3)
val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3)
assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder()
assertThat(seated + overflow).containsExactlyElementsIn(events)
assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day])
}
@Test
fun `a bar beyond the cap overflows on every day it covers`() {
val bars = (0 until 4).map {
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
}
val week = rowOfJuly6(bars)
val parked = week.spans.filter { it.lane >= 3 }.map { it.event }
(0..2).forEach { col ->
val day = LocalDate(2026, 7, 6 + col)
assertThat(week.overflowEvents(col, day, laneCap = 3))
.containsExactlyElementsIn(parked)
}
}
@Test
fun `a day that fits has no overflow`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)))
assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty()
}
private companion object {
const val BLUE = 0xFF3366CC.toInt()
const val RED = 0xFFCC3333.toInt()

View File

@@ -1,89 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class WidgetScaleTest {
@Test
fun `the on-device calibration points map to their tiers`() {
// The two sizes measured on-device: the compact widget stays COMPACT (the
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
// which read as too big on a phone (#51).
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
}
@Test
fun `a full-width widget at ordinary height still scales up`() {
// The regression the height cap used to cause: widening the widget without
// also making it unusually tall is *the* resize #51 reports, and it must
// reach the tier its width earned. Three cells tall is about 270dp.
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `width buckets into the four tiers`() {
// Tall enough that the height cap never binds, isolating the width rule.
val h = 500.dp
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `extra height never raises the tier`() {
// Height decides how many rows are visible, not how big they are: a tall,
// narrow widget wants more events, not bigger text.
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
}
@Test
fun `only a genuinely squashed widget is stepped back down`() {
// Same (wide) width, shrinking height. The cap exists to stop oversized type
// surviving in a one- or two-row sliver — it must not fire at normal heights.
// Heights are gross; the cap works on height minus 60dp of chrome, so the
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
val wide = 378.dp
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
}
@Test
fun `the tier is monotonic in both axes`() {
// Growing a widget must never make its type smaller. Sweeps the whole
// plausible range rather than spot-checking, so a future threshold edit
// can't accidentally invert a step.
val widths = (110..900 step 7).map { it.dp }
val heights = (110..900 step 7).map { it.dp }
widths.forEach { w ->
heights.zipWithNext { shorter, taller ->
assertThat(scaleFor(DpSize(w, taller)))
.isAtLeast(scaleFor(DpSize(w, shorter)))
}
}
heights.forEach { h ->
widths.zipWithNext { narrower, wider ->
assertThat(scaleFor(DpSize(wider, h)))
.isAtLeast(scaleFor(DpSize(narrower, h)))
}
}
}
}

View File

@@ -1,11 +1,9 @@
package de.jeanlucmakiola.calendula.widget.agenda
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.widget.WidgetScale
import de.jeanlucmakiola.calendula.widget.scaleFor
import de.jeanlucmakiola.calendula.widget.WidgetSize
import org.junit.jupiter.api.Test
class AgendaScaleTest {
@@ -13,23 +11,10 @@ class AgendaScaleTest {
// --- the "default size is unchanged" regression guard ---------------------
@Test
fun `every default placement width stays COMPACT`() {
// Ties the guarantee to the provider XML's declared 3-cell default rather
// than to one measured launcher: the whole band a default placement can
// land in must bucket to COMPACT, or a freshly placed widget silently
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
val band = AGENDA_DEFAULT_WIDTH_BAND
var w = band.start
while (w <= band.endInclusive) {
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
w += 1.dp
}
}
@Test
fun `COMPACT metrics equal the widget's original constants`() {
// If this fails, a default-sized agenda widget no longer looks as it did.
val m = metricsFor(WidgetScale.COMPACT)
fun `SMALL metrics equal the widget's original constants`() {
// SMALL is the default, so if this fails an agenda widget whose owner never
// touched the size setting no longer looks as it did (#51).
val m = metricsFor(WidgetSize.SMALL)
assertThat(m.title).isEqualTo(16.sp)
assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp)
@@ -54,10 +39,10 @@ class AgendaScaleTest {
// --- the ramp ------------------------------------------------------------
@Test
fun `sizes are non-decreasing across the tiers`() {
val tiers = WidgetScale.entries.map(::metricsFor)
fun `sizes are non-decreasing across the steps`() {
val steps = WidgetSize.entries.map(::metricsFor)
tiers.zipWithNext { small, big ->
steps.zipWithNext { small, big ->
assertThat(big.title.value).isAtLeast(small.title.value)
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
@@ -76,16 +61,16 @@ class AgendaScaleTest {
fun `the event title keeps its lead over the time line`() {
// The secondary line steps more slowly on purpose; if it ever caught up the
// row would lose its hierarchy.
WidgetScale.entries.map(::metricsFor).forEach { m ->
WidgetSize.entries.map(::metricsFor).forEach { m ->
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
}
}
@Test
fun `no tier grows type more than half again over the baseline`() {
fun `no step grows type more than half again over the baseline`() {
// Guards against a future edit turning "more readable" into "absurd".
val base = metricsFor(WidgetScale.COMPACT)
val top = metricsFor(WidgetScale.XLARGE)
val base = metricsFor(WidgetSize.SMALL)
val top = metricsFor(WidgetSize.EXTRA_LARGE)
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
}
@@ -96,7 +81,7 @@ class AgendaScaleTest {
fun `the stripe tracks the system font scale`() {
// The stripe is Dp, the text beside it is sp: without this the two diverge
// at large accessibility font settings and the stripe under-runs the row.
val m = metricsFor(WidgetScale.COMPACT)
val m = metricsFor(WidgetSize.SMALL)
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
@@ -104,7 +89,7 @@ class AgendaScaleTest {
@Test
fun `scaling for the default font scale changes nothing`() {
val m = metricsFor(WidgetScale.LARGE)
val m = metricsFor(WidgetSize.LARGE)
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
}
@@ -112,7 +97,7 @@ class AgendaScaleTest {
fun `font scaling leaves the sp sizes alone`() {
// Glance already applies the font scale to sp; scaling them here too would
// double-count it.
val m = metricsFor(WidgetScale.REGULAR)
val m = metricsFor(WidgetSize.MEDIUM)
val scaled = m.scaledForFont(1.3f)
assertThat(scaled.title).isEqualTo(m.title)
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)

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
(see the roadmap's idea backlog).
(the crash reporter's web-issue path is the worked example).
## Layers
@@ -62,9 +62,18 @@ 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/` (OptionCard — the app's only
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
drawer, transitions).
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.
## Navigation
@@ -81,7 +90,7 @@ exactly like an event tap.
## Recurring writes
The provider's invariants drive the design (learned the hard way, verified
on-device — see plan 03):
on-device):
- Recurring rows carry `RRULE` + `DURATION` (no `DTEND`); one-off rows
carry `DTEND`.
@@ -175,7 +184,12 @@ an app update or a doze window costs nothing. A first-ever scan claims the
present rather than the epoch, and a watermark left in the future by a clock
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
matter because both wipe pending alarms.
matter because both wipe pending alarms. Turning reminders off cancels the alarm
and granting the calendar permission arms none, so those transitions scan too —
without it, switching reminders back on would sit silent until the daily worker.
The window a scan reads is the 7-day lookahead plus the longest reminder offset
in the provider, capped at a year: that offset is whatever the largest row says,
including one imported from a stray `TRIGGER:-P100W`.
**All-day reminders fire at the hour the setting names.** The stored offset is
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
@@ -183,7 +197,13 @@ sampled against one date's UTC offset — so taking it at face value drifts by t
offset delta across a DST boundary, and rows from other apps carry no hour at
all. The offset is therefore read only for *which day* it means; the hour comes
from the global all-day reminder setting, recomposed against each occurrence's
own date. Timed reminders need none of this: `begin` is an absolute instant.
own date. Which day that is comes from the local date the encoded instant falls
on — except for a plain multiple of 1440, read at face value because a foreign
row means literal days from UTC midnight. The two collide where the all-day hour
equals the zone's UTC offset (20:00 in New York), and there the instant landing
on the named hour decides it is ours; the display path decodes through the same
function, so the screen and the notification agree. Timed reminders need none of
this: `begin` is an absolute instant.
**One visibility model.** The scan only plans occurrences of calendars with
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
@@ -213,6 +233,7 @@ 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
(Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a
(Forgejo Actions on Codeberg) 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.

Some files were not shown because too many files have changed in this diff Show More