Compare commits
10 Commits
ca2069da0d
...
v2.17.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e51e33356 | ||
|
|
e8657117d6 | ||
|
|
d5c53df6b5 | ||
|
|
2f4e05899a | ||
| 06653bf5b1 | |||
| e952393116 | |||
| 384ba39cf9 | |||
|
|
3fca28810b | ||
|
|
c988e1f028 | ||
|
|
a3d136f55d |
30
.forgejo/ISSUE_TEMPLATE/bug_report.md
Normal file
30
.forgejo/ISSUE_TEMPLATE/bug_report.md
Normal 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 -->
|
||||
|
||||
18
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
18
.forgejo/ISSUE_TEMPLATE/config.yml
Normal 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.
|
||||
@@ -5,7 +5,7 @@ title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority:high
|
||||
- priority/high
|
||||
---
|
||||
|
||||
<!--
|
||||
41
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
41
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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
16
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.1–v2.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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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 31–32 (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.
|
||||
@@ -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.1–v2.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 2–4: 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.1–v2.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**.
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -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,12 +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 → Agenda → Agenda
|
||||
widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||
- 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
|
||||
@@ -1223,3 +1252,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||
|
||||
184
CONTRIBUTING.md
Normal file
184
CONTRIBUTING.md
Normal 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
10
Gemfile
Normal 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
101
README.md
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||
<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">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">
|
||||
<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/)**
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -73,9 +73,8 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
||||
/**
|
||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||
* detail screen. Delegates to [allDayLeadDays] so what is displayed is the day
|
||||
* the reminder actually fires on; see there for how an encoded row is told from
|
||||
* a plain one written by another calendar app.
|
||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||
* the reminder actually fires on.
|
||||
*/
|
||||
internal fun fromProviderAllDayMinutes(
|
||||
rawMinutes: Int,
|
||||
|
||||
@@ -117,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?
|
||||
|
||||
@@ -405,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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -93,13 +93,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private const val EXTRA_ALL_DAY = "all_day"
|
||||
|
||||
/**
|
||||
* An explicit intent to this receiver carrying [alert] as extras.
|
||||
*
|
||||
* The data URI names the reminder and carries no information the extras
|
||||
* don't. It is what actually keeps two reminders' `PendingIntent`s
|
||||
* apart: `filterEquals` compares action, component, data, type and
|
||||
* categories — never extras — so without it a request-code collision
|
||||
* would have them share one alarm and one payload.
|
||||
* 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 {
|
||||
@@ -116,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 can make two
|
||||
* *different* reminders share a code, which the per-reminder data URI in
|
||||
* [intent] separates.
|
||||
* 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) {
|
||||
|
||||
@@ -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 31–32 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 31–32 with the permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderAlarmScheduler @Inject constructor(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 31–32 where the user revoked it).
|
||||
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
|
||||
* exact-alarm capability (API 31–32 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 31–32): an inexact wake is the honest
|
||||
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
||||
// available without nagging for SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,31 +10,18 @@ import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
|
||||
* instead of as words.
|
||||
* (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.
|
||||
*
|
||||
* A recurrence rule is the one place in the editor where a correct-sounding
|
||||
* phrase can still mean something the user didn't intend — "every 2 weeks on
|
||||
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
|
||||
* themselves. This is a *preview*, deliberately kept to the shapes the picker
|
||||
* can build; the provider remains the authority on what actually gets stored
|
||||
* and expanded.
|
||||
* 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.
|
||||
*
|
||||
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
|
||||
* engine implements):
|
||||
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
|
||||
* rather than sliding to the month's last day: the 31st recurring monthly
|
||||
* lands only in 31-day months, 29 February yearly only in leap years.
|
||||
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
|
||||
* with the week beginning on **Monday** — the RFC's default WKST, and the one
|
||||
* that applies here because [toRRule] never writes a WKST part. The
|
||||
* display-only "first day of week" preference deliberately doesn't enter into
|
||||
* it, or the preview would disagree with the stored rule.
|
||||
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
|
||||
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
|
||||
*
|
||||
* Returns fewer than [limit] dates when the series ends first, and an empty
|
||||
* list when the rule yields nothing at all (an UNTIL before the start).
|
||||
* 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()
|
||||
@@ -42,15 +29,17 @@ fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<Loc
|
||||
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||
val wanted = minOf(limit, maxCount)
|
||||
if (wanted <= 0) return emptyList()
|
||||
if (until != null && start > until) return emptyList()
|
||||
|
||||
val result = mutableListOf<LocalDate>()
|
||||
// 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 that produce nothing (a skipped 31st, a weekday block whose picks
|
||||
// all fall before the start) must not stall the walk, so the cap is on
|
||||
// periods examined rather than on dates found.
|
||||
// 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 (date <= start) continue
|
||||
if (until != null && date > until) return result
|
||||
result += date
|
||||
if (result.size == wanted) return result
|
||||
@@ -67,8 +56,8 @@ private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate):
|
||||
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||
RecurrenceFreq.Monthly -> {
|
||||
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||
// plus() clamps the day into the shorter month; the rule instead
|
||||
// skips such a period, so a clamped date means "not this 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 ->
|
||||
@@ -96,13 +85,9 @@ private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): L
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of [occurrences] starting at [start] leaves its starting year —
|
||||
* i.e. whether showing them without a year would be ambiguous.
|
||||
*
|
||||
* Day and month alone are enough for a rule that stays inside one year, and
|
||||
* dropping the year keeps the line short. They are actively misleading
|
||||
* otherwise: a yearly rule reads as the same date three times over, and a
|
||||
* monthly one rolling past December gives no hint that it has.
|
||||
* 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
|
||||
@@ -114,9 +99,8 @@ private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* How many repetitions to examine before giving up. Generous enough for the
|
||||
* sparsest rule the picker can build (29 February yearly with a 999-year
|
||||
* interval is nonsense; 31st monthly needs at most a handful), and bounded so a
|
||||
* rule whose occurrences all fall outside its own UNTIL can't spin.
|
||||
* 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
|
||||
|
||||
@@ -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,15 @@ private fun allDayDate(beginMillis: Long): LocalDate =
|
||||
/**
|
||||
* How many whole days before its occurrence a raw all-day offset means.
|
||||
*
|
||||
* Normally the local date of the encoded fire instant answers it: our own rows
|
||||
* fold the wanted hour into the offset, so that date *is* the day the reminder
|
||||
* belongs to, whatever the offset was when it was written.
|
||||
* 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.
|
||||
*
|
||||
* A plain multiple of 1440 is the exception. Those come from calendar apps that
|
||||
* store a bare lead time against UTC midnight, and east of UTC both readings
|
||||
* agree anyway — but west of UTC the instant falls on the previous local date,
|
||||
* so the day count has to be taken at face value or it comes out one too many.
|
||||
*
|
||||
* Except when the row is one of ours after all: our offset lands on a multiple
|
||||
* whenever the all-day hour equals the zone's UTC offset (20:00 in New York,
|
||||
* 19:00 an hour further west, and so on), and reading those at face value fired
|
||||
* them a day late — a "1 day before" arriving on the morning of the event. So
|
||||
* the hour decides, not the shape of the number: an instant landing on the hour
|
||||
* the setting names is ours. Within an hour or so of it counts, because a fixed
|
||||
* offset written in one DST phase drifts by the offset delta in the other.
|
||||
*
|
||||
* The two are genuinely indistinguishable in that band — the encodings collide
|
||||
* exactly there — so the tie goes to the reading that honours the lead time the
|
||||
* user chose in this app.
|
||||
*
|
||||
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] calls
|
||||
* this for display, so the notification arrives on the day the event screen says
|
||||
* it will.
|
||||
* Also used by
|
||||
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
|
||||
* display, so screen and notification agree.
|
||||
*/
|
||||
internal fun allDayLeadDays(
|
||||
rawMinutes: Int,
|
||||
@@ -185,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>,
|
||||
@@ -225,31 +169,20 @@ 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).
|
||||
*
|
||||
* The stretch is capped at [MAX_REMINDER_LEAD_MILLIS]. `maxReminderMinutes` is
|
||||
* whatever the largest row in the whole provider says, across every calendar and
|
||||
* whoever wrote it — an imported `TRIGGER:-P100W`, or a sync adapter writing
|
||||
* nonsense, would otherwise make every scan (launch, every provider change, every
|
||||
* alarm) expand every recurring series over years. A lead beyond the cap is not
|
||||
* delivered; a year is far past anything the picker composes.
|
||||
* 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 + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||
|
||||
@@ -149,9 +149,8 @@ fun CalendarHost(
|
||||
// over Settings and survives view switches.
|
||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Backup & restore (#69) — like the manager, driven by the calendar list
|
||||
// rather than by preferences, so it is hoisted here instead of living as a
|
||||
// Settings sub-section. Reached from Settings and from the manager.
|
||||
// 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:
|
||||
@@ -486,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(),
|
||||
@@ -509,12 +506,15 @@ fun CalendarHost(
|
||||
) {
|
||||
BackupScreen(
|
||||
onBack = { showBackup = false },
|
||||
// Restoring runs the normal .ics import, and that way round this
|
||||
// screen has to step aside: declared above the import overlays,
|
||||
// it would otherwise cover the screen it just asked for. Closing
|
||||
// it hands the user back to whatever opened Backup once the
|
||||
// import is done.
|
||||
onImport = { importUri = it; importForceMany = true; showBackup = false },
|
||||
// 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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ fun RootScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Whether the app came up already holding it — a launch scan has covered
|
||||
// that case, so only a grant made during this session owes a re-scan.
|
||||
// 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
|
||||
@@ -86,12 +86,11 @@ 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.
|
||||
// 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()
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -59,10 +59,8 @@ import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
// 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.
|
||||
// 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",
|
||||
@@ -70,11 +68,8 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69). Local calendars aren't synced anywhere, so a `.ics`
|
||||
* export is their only safety net — that made it a data-safety feature hiding in
|
||||
* the calendar manager, where nobody went looking. It now has its own Settings
|
||||
* entry, sharing [CalendarsViewModel] with the manager (both are driven by the
|
||||
* same calendar list).
|
||||
* 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.
|
||||
@@ -92,29 +87,24 @@ fun BackupScreen(
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Export covers the user's own local calendars (managed special-dates
|
||||
// mirrors don't count — they are rebuilt from contacts). Restore can target
|
||||
// any calendar the import picker would offer, so its availability is broader.
|
||||
// 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 }
|
||||
|
||||
// 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.
|
||||
// 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) }
|
||||
|
||||
// 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).
|
||||
// Restore runs the picked file through the normal .ics import flow.
|
||||
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.
|
||||
// The VM persists the write grant so background runs can keep writing.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||
@@ -148,15 +138,12 @@ fun BackupScreen(
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
|
||||
if (exportable.isNotEmpty()) {
|
||||
// One connected card: the one-time export on top, restore under it,
|
||||
// then automatic backup (and its folder/interval rows when on).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
// With more than one exportable calendar, let the user choose
|
||||
// which to include; a single one exports straight away.
|
||||
// A single exportable calendar skips the selector.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
@@ -198,9 +185,8 @@ fun BackupScreen(
|
||||
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.
|
||||
// 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(
|
||||
@@ -239,11 +225,8 @@ private fun ExportCalendarPicker(
|
||||
onExport: (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.
|
||||
// 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() },
|
||||
@@ -348,7 +331,7 @@ private fun BackupIntervalDialog(
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||
// 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)
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -107,14 +107,11 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
|
||||
/**
|
||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||
* calendars with create / rename / recolor / delete (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.
|
||||
* 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, because the two are looked for separately: "which
|
||||
* calendars do I have" versus "keep a copy of them". A full-screen destination;
|
||||
* [onBack] pops it.
|
||||
* screen only points at it. [onBack] pops the destination.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
@@ -252,9 +249,7 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
// Backup lives in its own Settings entry now (#69) — this row is the
|
||||
// pointer, so someone looking at their local calendars still finds the
|
||||
// way to keep a copy of them.
|
||||
// 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),
|
||||
@@ -314,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
|
||||
@@ -392,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,
|
||||
@@ -523,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? {
|
||||
@@ -543,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(
|
||||
|
||||
@@ -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,27 +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.
|
||||
*
|
||||
* A reminder that came due while the calendar was off stays gone when it is
|
||||
* switched back on. Delivery plans from `Instances` and `Reminders` on every
|
||||
* scan (#75), but the watermark has already moved past that moment, so no
|
||||
* scan returns it again — and on a read-only install the switch never
|
||||
* reaches the provider at all, so nothing even triggers one. Deliberate: the
|
||||
* reminder was silenced on purpose, and the event itself is back in view.
|
||||
* 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)
|
||||
|
||||
@@ -9,13 +9,8 @@ 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 account and a
|
||||
* DAVx5 account can carry the same address and still be two separate accounts,
|
||||
* from separate apps: merging them mixed their calendars into one group whose
|
||||
* header — source logo, "manage in app", toggle-all, collapsed state — was
|
||||
* derived from whichever calendar happened to sort first.
|
||||
* 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. */
|
||||
@@ -69,9 +64,8 @@ fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||
}
|
||||
|
||||
/**
|
||||
* The human name of the app backing [accountType] — the same app whose icon
|
||||
* [SourceLogo] draws. Falls back to the raw account type, which is at least
|
||||
* unique, when no installed app resolves for it.
|
||||
* 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -59,10 +59,9 @@ import kotlin.time.Clock
|
||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||
* back.
|
||||
*
|
||||
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
|
||||
* all-day pickers, where the lead time alone doesn't say when anything happens:
|
||||
* the hour comes from the separate "show all-day reminders at" setting, so
|
||||
* "1 day before" is only half the answer until the row spells out the time.
|
||||
* [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(
|
||||
@@ -233,10 +232,8 @@ internal fun PickerDescription(text: String) {
|
||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||
*
|
||||
* Every option carries the dates it resolves to today as its summary, computed
|
||||
* through the same [dayCount] the agenda windows by (hence [weekStart], which
|
||||
* "This week" depends on). Without it the two groups are near-indistinguishable
|
||||
* — "This week" and "Next 7 days" name different windows in the same words, and
|
||||
* only the concrete span tells them apart.
|
||||
* 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(
|
||||
@@ -260,11 +257,11 @@ fun AgendaRangePicker(
|
||||
val locale = currentLocale()
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
// The window each option would open right now — the agenda's own end-day
|
||||
// arithmetic (anchor + dayCount - 1), so the dates match what it will show.
|
||||
// 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)
|
||||
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
@@ -306,8 +303,8 @@ fun AgendaRangePicker(
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
// Only a chosen custom window has dates to show; an unset one has no
|
||||
// day count yet, so the row stays single-line until it does.
|
||||
// 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,
|
||||
|
||||
@@ -105,29 +105,19 @@ 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.
|
||||
*
|
||||
* The phrase [recurrenceText] renders says what the rule *is*; this says what
|
||||
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
|
||||
* rule with weekday picks gets wrong in people's heads. It expands the rule
|
||||
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
|
||||
* its limits: only the shapes the picker itself can build.
|
||||
*
|
||||
* A rule that yields nothing at all (an end date before the start) says so
|
||||
* rather than showing an empty list, since that is a mistake worth catching
|
||||
* before saving.
|
||||
* 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,
|
||||
// Spelled out: in this file, the bare LocalDate is java.time's.
|
||||
start: kotlinx.datetime.LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
||||
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
||||
// Years only once they carry information — a yearly rule is otherwise the
|
||||
// same date repeated, and a monthly one crossing New Year hides that it did.
|
||||
// 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()) }
|
||||
|
||||
@@ -515,8 +515,8 @@ private fun EventEditContent(
|
||||
// they're locked here; everything else (reminders, location, notes) is the
|
||||
// user's to edit.
|
||||
val locked = state.isManaged
|
||||
// Read in the form's own window, not the picker's: the 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) }
|
||||
@@ -1124,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)
|
||||
@@ -1143,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
|
||||
@@ -1344,10 +1340,7 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
||||
*
|
||||
* Both steps show the rule as *dates* as well as words: every preset row
|
||||
* carries the next few occurrences it would produce from [startDate], and the
|
||||
* custom step repeats that under its live read-out. It is where a rule most
|
||||
* easily means something other than it sounds like — "monthly" on the 31st
|
||||
* skips February, an every-other-week rule lands a fortnight from the start's
|
||||
* own week — and only the dates say so.
|
||||
* custom step repeats that under its live read-out.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecurrencePickerDialog(
|
||||
@@ -1409,9 +1402,8 @@ private fun RecurrencePickerDialog(
|
||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||
}
|
||||
// Kept as the rule object, not just its RRULE text: the read-out renders the
|
||||
// string, the date list expands the rule, and both must describe the one
|
||||
// thing OK would save.
|
||||
// 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,
|
||||
@@ -1505,9 +1497,8 @@ private fun RecurrencePickerDialog(
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
// The same rule as dates. Rendered even while the form is
|
||||
// incomplete (as an empty line) so the controls below don't
|
||||
// shift as it comes and goes.
|
||||
// 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,
|
||||
@@ -2034,10 +2025,8 @@ 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, because the four words
|
||||
* alone don't: visibility is about what *other people on a shared calendar*
|
||||
* see, which is exactly the part the label leaves out — and "Confidential" has
|
||||
* no meaning at all until you know the server decides what it does with it.
|
||||
* 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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -32,9 +32,8 @@ 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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -365,10 +365,8 @@ fun MonthScreen(
|
||||
todayText = stringResource(R.string.month_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Split has a selected day and lists it below the grid, so
|
||||
// that is the day the new event belongs to. The other
|
||||
// styles have no selection: anchor on today when its month
|
||||
// is shown, else the 1st.
|
||||
// Split has a selected day; the other styles anchor on
|
||||
// today when its month is shown, else the 1st.
|
||||
onCreateEvent(
|
||||
when {
|
||||
viewStyle == MonthViewStyle.Split -> selectedDate
|
||||
@@ -2075,10 +2073,9 @@ private fun MonthBar(
|
||||
}
|
||||
|
||||
/**
|
||||
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest.
|
||||
*
|
||||
* A dot stands for every hidden event sharing its colour, so it dims only once
|
||||
* all of them have ended; the "+N" dims once the whole overflow has (#79).
|
||||
* 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(
|
||||
|
||||
@@ -68,12 +68,9 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
}
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — the exact
|
||||
* complement of what it seats, in the same bars-then-pills order. Together the
|
||||
* two partition the day, so their sizes add up to [countByDay].
|
||||
*
|
||||
* The "+N" marker needs the events themselves, not just how many there are:
|
||||
* dimming a completed event is a per-event question (#79).
|
||||
* 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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -37,8 +37,7 @@ class ReminderOnboardingViewModel @Inject constructor(
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm, so a later "on" would sit without one until a provider
|
||||
// change or the daily worker happened to run (#75).
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -84,7 +84,6 @@ internal fun AppearanceScreen(
|
||||
|
||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||
val context = LocalContext.current
|
||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -107,8 +106,6 @@ internal fun AppearanceScreen(
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
// Says what it does when on; below Android 12 that is replaced by
|
||||
// the reason the switch is dead.
|
||||
summary = if (state.dynamicColorAvailable) {
|
||||
stringResource(R.string.settings_dynamic_color_summary)
|
||||
} else {
|
||||
@@ -143,9 +140,7 @@ internal fun AppearanceScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Fonts — the two Material typeface roles, each independently choosable
|
||||
// (issue #19). Headings = brand (display/headline); body = plain
|
||||
// (title/body/label). Both default to the system typeface.
|
||||
// Fonts — the two Material typeface roles (#19).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_font_headings),
|
||||
summary = fontLabel(fonts.brand),
|
||||
@@ -161,10 +156,8 @@ internal fun AppearanceScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||
// (issue #44). Own group: it's a launcher/system concern, not app styling.
|
||||
// A sub-page chooser (not a switch), matching the app's other "choose one"
|
||||
// settings and leaving room for more names later.
|
||||
// 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),
|
||||
@@ -179,10 +172,8 @@ internal fun AppearanceScreen(
|
||||
onDismiss = { showAppName = false },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// Show both names as launcher-mark previews so the user sees what
|
||||
// they'd switch to, not just the current state. Tapping applies
|
||||
// immediately and highlights — the picker stays open so the change is
|
||||
// visible; back exits.
|
||||
// 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,
|
||||
@@ -211,10 +202,8 @@ internal fun AppearanceScreen(
|
||||
}
|
||||
}
|
||||
if (showTheme) {
|
||||
// No preview here on purpose: picking a theme repaints the app itself,
|
||||
// which is a better demonstration than any thumbnail. What the list
|
||||
// *can't* show is which way "Follow the system" currently falls, so that
|
||||
// one option says so.
|
||||
// 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),
|
||||
@@ -307,19 +296,8 @@ private val FONT_PICKER_MIME_TYPES = arrayOf(
|
||||
|
||||
/**
|
||||
* Full-screen font chooser for one [FontRole]: the system default, each bundled
|
||||
* font (previewed in its own face), and "Choose file…" which opens the system
|
||||
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
|
||||
* a file is validated and imported by the caller, switching to the custom font
|
||||
* on success.
|
||||
*
|
||||
* Above the rows sits a specimen of the *current* choice, set in the type role
|
||||
* this picker governs — headline for [FontRole.BRAND], body for
|
||||
* [FontRole.PLAIN]. The per-row "Ag" says what a face looks like; only a full
|
||||
* line at the real size says whether it works for the role, and it is the one
|
||||
* thing that makes the two font settings distinguishable from each other.
|
||||
*
|
||||
* Being a preview picker, it stays open on selection (see `FullScreenPicker`):
|
||||
* the specimen re-renders instead, which is the whole point of the screen.
|
||||
* 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(
|
||||
@@ -335,22 +313,17 @@ private fun FontPicker(
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
// Stay open on a successful import too: the specimen switches to the
|
||||
// imported face, which is the only look the user gets before committing.
|
||||
if (uri != null) onImport(uri)
|
||||
}
|
||||
|
||||
// System default + the bundled fonts + the "Choose file…" row.
|
||||
val rowCount = BundledFont.entries.size + 2
|
||||
val isCustom = selected == FONT_CUSTOM_TOKEN
|
||||
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
|
||||
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
|
||||
// refreshes the preview while plain recompositions reuse the cached family.
|
||||
// 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
|
||||
}
|
||||
// The face the specimen is set in: the same resolution the theme performs,
|
||||
// so what is shown here is what the app will use.
|
||||
val selectedFamily = remember(role, selected, stamp) {
|
||||
resolveFontFamily(selected, role, context)
|
||||
}
|
||||
@@ -362,8 +335,6 @@ private fun FontPicker(
|
||||
preview = FontFamily.Default,
|
||||
selected = selected == FONT_SYSTEM_TOKEN,
|
||||
position = positionOf(0, rowCount),
|
||||
// Applies straight away and stays open — the specimen above is the
|
||||
// answer to "what does this one look like".
|
||||
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
|
||||
)
|
||||
BundledFont.entries.forEachIndexed { index, font ->
|
||||
@@ -381,7 +352,6 @@ private fun FontPicker(
|
||||
} else {
|
||||
stringResource(R.string.settings_font_choose_file)
|
||||
},
|
||||
// A loaded font previews in its own face; otherwise show an upload cue.
|
||||
preview = customPreview,
|
||||
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
||||
selected = isCustom,
|
||||
@@ -392,11 +362,8 @@ private fun FontPicker(
|
||||
}
|
||||
|
||||
/**
|
||||
* A specimen of [family] set in the type role [role] governs: a headline for
|
||||
* the brand role, a paragraph for the plain one — the same styles the app draws
|
||||
* with, only the family swapped, so nothing here can flatter a face the app
|
||||
* won't reproduce. A null [family] is the system typeface, i.e. the Material
|
||||
* default the styles already carry.
|
||||
* 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?) {
|
||||
@@ -412,8 +379,8 @@ private fun FontSpecimen(role: FontRole, family: FontFamily?) {
|
||||
),
|
||||
style = style.copy(fontFamily = family ?: style.fontFamily),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
// Two lines up front so switching between a wide and a narrow face
|
||||
// doesn't shuffle the option list up and down under it.
|
||||
// Fixed height, so switching between a wide and a narrow face doesn't
|
||||
// shuffle the option list under it.
|
||||
minLines = 2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -422,9 +389,8 @@ private fun FontSpecimen(role: FontRole, family: FontFamily?) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
|
||||
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
|
||||
* to preview), and a check when it's the current selection.
|
||||
* 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(
|
||||
@@ -460,11 +426,8 @@ private fun FontOptionRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* One selectable launcher-name preview in the App name picker (issue #44): the
|
||||
* app's launcher mark over the name, framed as a card. The active one carries a
|
||||
* primary border, a tinted container and a check; tapping selects it. The mark
|
||||
* is the same for both — only the label changes — so the card previews exactly
|
||||
* what the home screen will read.
|
||||
* 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(
|
||||
@@ -494,8 +457,8 @@ private fun AppNameOptionCard(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// The adaptive launcher mark, reconstructed as a squircle (as in the
|
||||
// onboarding BrandHero) so it renders identically everywhere.
|
||||
// The adaptive mark rebuilt as a squircle, as in the onboarding
|
||||
// BrandHero, so it renders identically everywhere.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
@@ -515,7 +478,6 @@ private fun AppNameOptionCard(
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
)
|
||||
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
|
||||
@@ -40,8 +40,7 @@ internal fun EventFormScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(eventFormFieldLabel(field)),
|
||||
position = positionOf(index, fields.size),
|
||||
// Same icon the field carries in the new-event form, so a toggle
|
||||
// is easy to match to the field it controls.
|
||||
// The same icon the field carries in the new-event form.
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = eventFormFieldIcon(field),
|
||||
@@ -59,9 +58,7 @@ internal fun EventFormScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-focus the title on a new event (issue #10) — on by default, since
|
||||
// most events get a title; raising the keyboard saves a tap. Off lets you
|
||||
// set the time/calendar first without the keyboard in the way.
|
||||
// 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),
|
||||
@@ -84,8 +81,7 @@ internal fun EventFormScreen(
|
||||
)
|
||||
|
||||
// Per-event colour on calendars that publish no colour set (some
|
||||
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||
// not survive their next sync. Local and palette calendars ignore it.
|
||||
// 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),
|
||||
|
||||
@@ -124,11 +124,8 @@ internal fun NotificationsScreen(
|
||||
onClick = { showAllDayReminderTime = true },
|
||||
)
|
||||
|
||||
// Delivery reliability + snooze: both are global reminder-delivery
|
||||
// settings, so they sit with the defaults above rather than below the
|
||||
// long per-calendar list. Reliability is a soft, optional battery-
|
||||
// optimisation exemption (system-settings deep-link, no special
|
||||
// permission); shown as live status, reversible at any time.
|
||||
// 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(
|
||||
@@ -147,7 +144,6 @@ internal fun NotificationsScreen(
|
||||
onClick = { openBatteryOptimizationSettings(context) },
|
||||
)
|
||||
|
||||
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||
@@ -155,10 +151,9 @@ internal fun NotificationsScreen(
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
|
||||
// Per-calendar overrides: the whole section folds behind one header to
|
||||
// keep the screen tidy. Expanded, each writable calendar gets its own
|
||||
// expandable card that may keep, drop, or replace the global default —
|
||||
// separately for timed and all-day events.
|
||||
// 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(
|
||||
@@ -182,8 +177,8 @@ internal fun NotificationsScreen(
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// A contact special-dates calendar owns its reminders in
|
||||
// its own section — link there instead of an override.
|
||||
// Special-dates calendars own their reminders in their
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
@@ -202,8 +197,6 @@ internal fun NotificationsScreen(
|
||||
return@forEach
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
// Calendar card; tapping expands it into a grouped list
|
||||
// of three (the card + the timed and all-day rows).
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
@@ -366,8 +359,7 @@ private fun snoozeDurationLabel(minutes: Int): String =
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
* `ON_RESUME` so the row reflects a change the user just made in system
|
||||
* settings without needing to leave and re-enter the screen.
|
||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||
@@ -392,10 +384,8 @@ private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the user straight to Calendula's exemption: the direct
|
||||
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
|
||||
* battery optimisation?") rather than the full app list they'd have to scroll.
|
||||
* Falls back to the optimisation list if the OS refuses the direct intent.
|
||||
* 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(
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -17,7 +18,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
@@ -41,19 +41,9 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* The "past events" chooser for the Agenda (#69 follow-up).
|
||||
*
|
||||
* "Show / dim / hide" is the case where the word cannot carry the choice —
|
||||
* *how far* does dimmed fade, and what is left behind when finished events are
|
||||
* hidden? So a stand-in agenda day sits above the options and re-renders as you
|
||||
* pick one, using the very rows the Agenda draws: dimming is
|
||||
* [AgendaEventRow]'s own `dimmed` flag, and hiding drops the row exactly as
|
||||
* [de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen] filters it — so the
|
||||
* preview cannot claim an effect the screen doesn't have.
|
||||
*
|
||||
* Like the Month style and App name pickers, selecting applies immediately and
|
||||
* leaves the picker open: closing would hide the thing the screen is for. Back
|
||||
* exits.
|
||||
* 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(
|
||||
@@ -102,7 +92,6 @@ internal fun PastEventsPicker(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Applies straight away; the preview above is the confirmation.
|
||||
onClick = { onSelect(mode) },
|
||||
)
|
||||
}
|
||||
@@ -111,9 +100,8 @@ internal fun PastEventsPicker(
|
||||
|
||||
/**
|
||||
* A stand-in agenda day in a given [mode]: three events on today, the first two
|
||||
* already finished. The times are fixed rather than derived from the clock, so
|
||||
* the preview reads the same at any hour — what varies is only what [mode] does
|
||||
* to the two finished rows.
|
||||
* 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) {
|
||||
@@ -125,24 +113,17 @@ private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modif
|
||||
stringResource(R.string.settings_past_events_sample_evening),
|
||||
)
|
||||
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
|
||||
// Hiding drops the finished rows outright; showing and dimming keep them,
|
||||
// and only dimming fades them — the same three-way split AgendaContent makes.
|
||||
// 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 preview is a picture, not a control: swallow touches before the
|
||||
// rows' own clickables see them, and keep it out of the reading order —
|
||||
// the options below already say what is selected.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 = {})
|
||||
@@ -163,11 +144,8 @@ private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modif
|
||||
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
|
||||
|
||||
/**
|
||||
* Today's stand-in events, [titles] in order: two finished (morning, midday) and
|
||||
* one still to come.
|
||||
*
|
||||
* Colours are raw ARGB on purpose — that is what the provider hands out for an
|
||||
* event, so a theme token here would misrepresent what the agenda renders.
|
||||
* 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(
|
||||
|
||||
@@ -31,23 +31,13 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
*/
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||
* Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
|
||||
* to theme, dark mode and dynamic colour.
|
||||
*
|
||||
* The chips are a scanning aid, so the rule is **no two rows in one group share
|
||||
* an accent** — a group in a single colour carries no more information than no
|
||||
* colour at all. [Neutral] is not a fourth colour to rotate through but a
|
||||
* deliberate step back, for rows that are reference or last-resort rather than
|
||||
* somewhere you routinely go.
|
||||
* 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. Colours come from the M3 scheme via a container /
|
||||
* on-container token pair, so each accent stays correctly paired across theme,
|
||||
* dark mode and dynamic colour.
|
||||
*/
|
||||
/** Leading circular icon chip, coloured by an M3 container/on-container pair. */
|
||||
@Composable
|
||||
internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -75,9 +65,7 @@ internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||
|
||||
/**
|
||||
* A small primary-coloured group label, matching the Calendars settings screen.
|
||||
*
|
||||
* Starts on [GroupedListInset] — the cards' own edge — so a header, the hint
|
||||
* under it and the group it names all share one left margin.
|
||||
* Inset to [GroupedListInset] so it shares the cards' left margin.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SectionHeader(text: String) {
|
||||
@@ -117,34 +105,18 @@ internal fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Lead times offered for the all-day default — day-scale, since a "minutes
|
||||
* before midnight" reminder on an all-day event is rarely what's wanted. Shared
|
||||
* with the contact special-dates calendars, whose events are all all-day.
|
||||
*/
|
||||
/** 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 ("09:00") in the app's *own* 12/24-hour convention — the same
|
||||
* [LocalUse24HourFormat] the agenda and event rows read, so a time shown in
|
||||
* settings can't disagree with the times shown everywhere else.
|
||||
*/
|
||||
/** 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.
|
||||
*
|
||||
* An all-day occurrence has no time of its own, so a reminder's stored offset
|
||||
* only decides *which day* it belongs to — the hour always comes from the
|
||||
* global "show all-day reminders at" setting (see
|
||||
* [de.jeanlucmakiola.calendula.domain.reminders.planReminders]). Naming it on
|
||||
* every row is what makes "At time of event" readable on an all-day default at
|
||||
* all: it means that day at 09:00, not midnight.
|
||||
*
|
||||
* Shared by the notification defaults, the per-calendar overrides and the
|
||||
* contact special-dates calendars — all three read the same one setting.
|
||||
* 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? {
|
||||
|
||||
@@ -70,15 +70,10 @@ import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
|
||||
|
||||
/**
|
||||
* Settings (M4), restructured in v2.3 into a category hub with sub-screens and
|
||||
* re-sorted in v2.17 (#69) so every setting sits where it is looked for.
|
||||
*
|
||||
* The hub reads as three labelled groups — what the app looks like and how it
|
||||
* behaves, what it does with your data, and the app itself — and each sub-screen
|
||||
* lives in its own `*Settings.kt` file. Calendars and Backup open screens
|
||||
* hoisted in `CalendarHost` (they are driven by the calendar list, not by
|
||||
* preferences); Language opens a full-screen picker; About is a card at the top.
|
||||
* A full-screen destination; [onBack] pops it.
|
||||
* Settings hub: three labelled groups of category rows, each opening a
|
||||
* sub-screen from its own `*Settings.kt` file (#69). Calendars and Backup open
|
||||
* screens hoisted in `CalendarHost`; Language opens a full-screen picker.
|
||||
* [onBack] pops the destination.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -166,18 +161,14 @@ private fun SettingsHub(
|
||||
onOpenBackup: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
||||
// The card and the support row are one grouped block: the call to action
|
||||
// is a row continuing the container rather than a tonal button sitting
|
||||
// inside the card.
|
||||
// Card and support row are one grouped block, so the call to action
|
||||
// continues the container instead of sitting inside it as a button.
|
||||
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
||||
SupportRow()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Three labelled groups instead of one long undifferentiated list (#69):
|
||||
// how the app presents itself, what it does with your data, and the app
|
||||
// as an installed thing. Each group cycles its chip accents so no two
|
||||
// rows in it look alike (see [ChipAccent]) — a uniformly coloured group
|
||||
// is as unscannable as an uncoloured one.
|
||||
// Each group cycles its chip accents so no two rows in it look alike
|
||||
// (see [ChipAccent]).
|
||||
SectionHeader(stringResource(R.string.settings_group_look))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
@@ -224,9 +215,8 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Primary) },
|
||||
onClick = { onOpenSection(SettingsSection.SpecialDates) },
|
||||
)
|
||||
// Export/import used to hide inside the calendar manager, where nobody
|
||||
// looked for it (#69). It is a data-safety feature, so it gets its own
|
||||
// top-level entry; the manager keeps a pointer row to here.
|
||||
// Export/import gets its own top-level entry (#69); the calendar
|
||||
// manager keeps a pointer row to here.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
summary = stringResource(R.string.settings_backup_subtitle),
|
||||
@@ -247,10 +237,8 @@ private fun SettingsHub(
|
||||
LanguageRow(position = Position.Middle)
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
// Source, licence and privacy sit at the very bottom rather than in the
|
||||
// About card: they are reference material you look up once, not settings
|
||||
// you come here to change, and the card at the top now leads with what
|
||||
// the app *is* plus the one link worth offering (support).
|
||||
// Source, licence and privacy sit at the bottom as reference material
|
||||
// rather than inside the About card.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_group_about))
|
||||
AboutLinkRow(
|
||||
@@ -364,11 +352,8 @@ private fun languageLabel(tag: String?): String =
|
||||
|
||||
@Composable
|
||||
private fun AboutCard() {
|
||||
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
||||
// supplies its own logo and author. Source, licence and privacy moved to
|
||||
// rows at the foot of the page, and support is now the [SupportRow] joined
|
||||
// to the bottom of this card — so the card itself carries nothing but what
|
||||
// the app is and who made it.
|
||||
// Layout lives in floret-kit (components.AboutCard); Calendula supplies
|
||||
// its own logo and author. Support is the [SupportRow] joined below.
|
||||
AboutCard(
|
||||
logo = { AppLogo() },
|
||||
appName = stringResource(R.string.app_name),
|
||||
@@ -378,11 +363,7 @@ private fun AboutCard() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Support development", as the closing row of the About card's group rather
|
||||
* than a tonal button inside it. Same weight as any other row you can tap, with
|
||||
* a primary chip to keep it the one accent in this block.
|
||||
*/
|
||||
/** "Support development", as the closing row of the About card's group. */
|
||||
@Composable
|
||||
private fun SupportRow() {
|
||||
val context = LocalContext.current
|
||||
@@ -396,10 +377,9 @@ private fun SupportRow() {
|
||||
}
|
||||
|
||||
/**
|
||||
* One reference link at the foot of the hub (source, licence, privacy): a plain
|
||||
* grouped row that opens the URL in the browser. The privacy policy in
|
||||
* particular has to stay reachable from inside the app, not only from the store
|
||||
* listing, because Calendula touches calendar and contact data.
|
||||
* One reference link at the foot of the hub (source, licence, privacy). The
|
||||
* privacy policy has to stay reachable from inside the app, not only from the
|
||||
* store listing.
|
||||
*/
|
||||
@Composable
|
||||
private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position: Position) {
|
||||
@@ -433,15 +413,9 @@ private fun AppVersionText() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The app icon as a rounded chip: the off-white launcher mark over its slate
|
||||
* background colour, rendered oversized and clipped to fill the chip the way a
|
||||
* launcher mask would.
|
||||
*
|
||||
* Sized just past the two text lines beside it, so the card's height is set by
|
||||
* its content rather than by the mark towering over it. That also keeps it a
|
||||
* step above the 40dp category chips on the rows below — still the block's
|
||||
* anchor, no longer its outlier. The mark is a vector, so the size is purely a
|
||||
* layout choice; [MARK_OVERSCAN] holds the launcher-mask crop as it shrinks.
|
||||
* The app icon as a rounded chip: the launcher mark over its background colour,
|
||||
* oversized and clipped the way a launcher mask would. Sized just past the two
|
||||
* text lines beside it; [MARK_OVERSCAN] holds the crop as it shrinks.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppLogo() {
|
||||
@@ -464,8 +438,7 @@ private fun AppLogo() {
|
||||
private val LOGO_SIZE = 56.dp
|
||||
|
||||
/**
|
||||
* How far the mark overruns its chip. An adaptive icon's foreground is drawn
|
||||
* with a wide safe margin, so at 1:1 the flower would sit small and lost inside
|
||||
* the chip; the launcher crops the same way.
|
||||
* How far the mark overruns its chip. An adaptive icon's foreground carries a
|
||||
* wide safe margin, so at 1:1 it would sit small and lost; launchers crop it too.
|
||||
*/
|
||||
private const val MARK_OVERSCAN = 1.5f
|
||||
|
||||
@@ -80,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 } }
|
||||
@@ -225,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()
|
||||
@@ -251,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(
|
||||
@@ -407,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)
|
||||
@@ -435,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 {
|
||||
@@ -466,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() }
|
||||
@@ -484,13 +469,9 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the size the agenda widget draws its text at (#51). Pushed into every
|
||||
* instance's Glance state and recomposed — the same reliable-update path as
|
||||
* [setAgendaWidgetRange], since `updateAll` alone won't re-run the data
|
||||
* preamble a live session already ran.
|
||||
*
|
||||
* Agenda-only on purpose: the month widget sizes itself from the space it is
|
||||
* given and always has, so it takes no size setting (#103).
|
||||
* 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 {
|
||||
@@ -508,9 +489,7 @@ class SettingsViewModel @Inject constructor(
|
||||
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 ->
|
||||
@@ -538,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
|
||||
@@ -556,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 ->
|
||||
@@ -582,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 {
|
||||
@@ -612,9 +586,8 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* A scan follows the write, in that order. Switching reminders off cancels
|
||||
* the scan alarm, so switching them back on has to arm a new one — nothing
|
||||
* else would until a provider change or the daily worker came along (#75).
|
||||
* 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 {
|
||||
|
||||
@@ -48,14 +48,10 @@ internal fun SpecialDatesScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||
// The all-day reminder hour lives in the general settings state; the reminder
|
||||
// picker below names it, since that is when these reminders actually fire.
|
||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// READ_CONTACTS is requested only here, on enable — never at startup. On a
|
||||
// grant we either enable (if turning on) or just re-sync (clearing a stalled
|
||||
// banner after the permission was re-granted).
|
||||
// READ_CONTACTS is requested only here, on enable — never at startup.
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
@@ -122,8 +118,6 @@ internal fun SpecialDatesScreen(
|
||||
)
|
||||
|
||||
if (state.enabled) {
|
||||
// Per-type card: the toggle, and while on, its title format and its
|
||||
// calendar-wide reminder.
|
||||
SpecialDateType.entries.forEachIndexed { index, type ->
|
||||
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
|
||||
val on = type in state.types
|
||||
@@ -222,8 +216,6 @@ internal fun SpecialDatesScreen(
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||
onDismiss = { reminderPickerType = null },
|
||||
// Special dates are all-day events, so their reminders fire at the
|
||||
// one global all-day hour like any other all-day reminder.
|
||||
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
@@ -268,8 +260,7 @@ private fun SpecialDatesTemplateDialog(
|
||||
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||
text = {
|
||||
Column {
|
||||
// The app's borderless input over a tonal surface (the dialog
|
||||
// convention — see DialogControls), not Material's outlined field.
|
||||
// The dialog convention — see DialogControls.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
|
||||
@@ -52,15 +52,9 @@ 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 first group holds what applies everywhere (default view, week start, time
|
||||
* format); the rest are per-view. Anything that styles the *app* rather than a
|
||||
* view (theme, fonts) is in [AppearanceScreen]; the widget's own copies of the
|
||||
* agenda settings are in [WidgetsScreen].
|
||||
*
|
||||
* The quick-switch cycle and the navigation-drawer list are two independent
|
||||
* orders — a view disabled in the quick-switch cycle is still reachable from the
|
||||
* drawer, which always lists every view. The switch needs at least two targets,
|
||||
* so the last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
|
||||
* 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(
|
||||
@@ -266,8 +260,6 @@ internal fun ViewsScreen(
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
// The same icon each view carries in the drawer and the switcher
|
||||
// pill, so the row is matched to the view by eye, not by reading.
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = it.icon,
|
||||
@@ -299,9 +291,8 @@ internal fun ViewsScreen(
|
||||
options = TimeFormatPref.entries,
|
||||
selected = state.timeFormat,
|
||||
label = { timeFormatLabel(it) },
|
||||
// Each option renders the same sample time the way it would write
|
||||
// it — an afternoon one, since that is where 12h and 24h diverge.
|
||||
// "Automatic" additionally says which of the two it resolves to now.
|
||||
// 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,
|
||||
@@ -335,8 +326,6 @@ internal fun ViewsScreen(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.settings_agenda_range_hint),
|
||||
selected = state.agendaScreenRange,
|
||||
// Resolving each option to real dates needs the same week start the
|
||||
// agenda itself windows by.
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setAgendaScreenRange,
|
||||
onDismiss = { showAgendaScreenRange = false },
|
||||
@@ -400,8 +389,7 @@ private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||
@Composable
|
||||
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||
// Localised full weekday name, so any of the seven days reads naturally
|
||||
// without a per-day string resource.
|
||||
// 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())
|
||||
}
|
||||
|
||||
@@ -31,20 +31,11 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* The week-start chooser, with the grid it rearranges shown above it.
|
||||
* 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.
|
||||
*
|
||||
* Which column a week opens on is the most visual setting in the app, and the
|
||||
* one hardest to hold in your head from a weekday name alone. The preview is
|
||||
* the same [MonthStylePreview] the Month style picker uses, drawn in the user's
|
||||
* *own* month style, so what moves is exactly what will move on the real
|
||||
* screen. It is short here (the option list runs to eight rows), which the
|
||||
* preview supports natively by scaling.
|
||||
*
|
||||
* "Follow the system" carries the day it currently resolves to as its summary —
|
||||
* otherwise the one automatic option is the only one whose effect is invisible.
|
||||
*
|
||||
* A preview picker, so selecting applies at once and keeps the picker open;
|
||||
* back exits (see `FullScreenPicker`).
|
||||
* A preview picker, so selecting applies at once and keeps the picker open.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WeekStartPicker(
|
||||
@@ -93,8 +84,6 @@ internal fun WeekStartPicker(
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = weekStartLabel(option),
|
||||
// Only the automatic option needs a second line: it is the one
|
||||
// whose effect the label doesn't name.
|
||||
summary = if (option == WeekStartPref.Auto) {
|
||||
stringResource(
|
||||
R.string.settings_week_start_auto_summary,
|
||||
|
||||
@@ -33,13 +33,9 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Widgets & tiles (#69): the home-screen widgets' own settings and the Quick
|
||||
* Settings tile shortcut — the app's system surfaces, collected in one place.
|
||||
*
|
||||
* The agenda widget keeps its own range and text size, deliberately separate
|
||||
* from the Agenda *screen*'s range in [ViewsScreen]: a widget is glanced at, a
|
||||
* screen is browsed, and having the two rows sit next to each other (as they did
|
||||
* before) made them easy to mistake for one another.
|
||||
* 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(
|
||||
@@ -71,9 +67,8 @@ internal fun WidgetsScreen(
|
||||
onClick = { showWidgetSize = true },
|
||||
)
|
||||
|
||||
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||
// is API 33+; on older versions the tile is still addable manually from
|
||||
// the QS editor, so the row simply doesn't appear here.
|
||||
// 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()
|
||||
@@ -98,10 +93,8 @@ internal fun WidgetsScreen(
|
||||
options = WidgetSize.entries,
|
||||
selected = state.widgetSize,
|
||||
label = { widgetSizeLabel(it) },
|
||||
// A size name says nothing about what you get; the text scale it
|
||||
// draws at does. No live preview here on purpose: the widget is
|
||||
// Glance/RemoteViews, so a Compose mock-up would be a second
|
||||
// implementation free to drift from the real thing.
|
||||
// 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 },
|
||||
@@ -138,9 +131,8 @@ private fun requestAddQsTile(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a size step actually does, as a percentage of the smallest one's event
|
||||
* text. Derived from the widget's own [metricsFor] table rather than written
|
||||
* out here, so a retuned step can't leave this line claiming the old number.
|
||||
* 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 {
|
||||
|
||||
@@ -2,19 +2,9 @@ package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
/**
|
||||
* The size step the **agenda** widget draws its text and rows at — a user
|
||||
* setting, not something derived from the widget's measured size (#51).
|
||||
* setting, not the measured size (#51), which a launcher reports inaccurately.
|
||||
* The month widget keeps sizing itself from the width it is given (#103).
|
||||
*
|
||||
* The agenda widget used to bucket its live size (`SizeMode.Exact` +
|
||||
* `LocalSize.current`) into a tier and scale from that. The size a launcher
|
||||
* reports is not the size the widget is drawn into, so the tier it picked could
|
||||
* disagree with what was on screen; a size the user sets is predictable and is
|
||||
* what the #51 thread actually asked for.
|
||||
*
|
||||
* The month widget deliberately has no size setting: its grid divides whatever
|
||||
* width it is given by seven and always has, which is the behaviour to keep
|
||||
* (#103).
|
||||
*
|
||||
* [SMALL] is the default and reproduces the widget's original constants, so an
|
||||
* existing widget looks as it did until its owner turns the size up.
|
||||
* [SMALL] is the default and reproduces the widget's original constants.
|
||||
*/
|
||||
enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }
|
||||
|
||||
@@ -8,10 +8,8 @@ import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/**
|
||||
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||
* size step — a wider stripe or gap would eat title width, which is the thing the
|
||||
* row is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||
* the "nothing left today" line can never drift out of alignment with the event
|
||||
* title column the way a hardcoded 19dp could.
|
||||
* 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
|
||||
@@ -39,34 +37,21 @@ 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 step'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:
|
||||
*
|
||||
* ‡ SMALL's 13sp day header is off-scale. It is held there deliberately —
|
||||
* SMALL reproduces the widget's original constants verbatim so a widget whose
|
||||
* owner never touches the size setting looks exactly as it did (#51), and
|
||||
* snapping it to Title Small (14sp) would break that promise for a 1sp gain.
|
||||
*
|
||||
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
|
||||
* which is far too coarse for four size steps. Where a role would force a
|
||||
* ≥1.4x step between adjacent steps we hold an interpolated value instead and
|
||||
* mark it. The endpoints stay on real roles.
|
||||
* ‡ 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 SMALL_METRICS = AgendaMetrics(
|
||||
|
||||
@@ -116,14 +116,10 @@ class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Single: type and row metrics come from the user's chosen WidgetSize, not
|
||||
// from the widget's measured size (#103, #51), so there is nothing to gain
|
||||
// from Glance building one RemoteViews per host size bucket — and plenty to
|
||||
// lose, since that roughly doubles the serialized payload. The row list is
|
||||
// still capped below: an uncapped agenda (the range goes up to
|
||||
// AgendaRange.MAX_CUSTOM_DAYS = 365) could push the RemoteViews past the
|
||||
// binder transaction limit and the host would just show "Problem loading
|
||||
// widget".
|
||||
// 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) {
|
||||
@@ -164,10 +160,8 @@ private sealed interface AgendaRow {
|
||||
|
||||
@Composable
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
// Type and row metrics come from the user's chosen size step, read reactively
|
||||
// from per-instance Glance state and falling back to the saved pref for a
|
||||
// freshly placed widget (#103, #51). The permission screen has no loaded prefs
|
||||
// to fall back to, so it takes the default.
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,13 +53,29 @@ class RecurrenceOccurrencesTest {
|
||||
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
|
||||
|
||||
// Thursday start: the Monday of that week is already past, so the first
|
||||
// hit is Friday 31 Jul, then Mon/Fri of the following weeks.
|
||||
// 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),
|
||||
date(2026, 8, 10),
|
||||
).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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 +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). `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`.
|
||||
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
|
||||
|
||||
@@ -83,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`.
|
||||
@@ -226,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.
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
# Building from source
|
||||
|
||||
Calendula builds with the standard Android Gradle toolchain — no extra setup
|
||||
beyond the SDK and a JDK.
|
||||
beyond the SDK, a JDK, and the submodule.
|
||||
|
||||
## Clone
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
||||
```
|
||||
|
||||
Calendula depends on **[floret-kit](https://codeberg.org/jlmakiola/floret-kit)**,
|
||||
the shared Material 3 Expressive kit, as a git submodule wired in as a Gradle
|
||||
composite build (`includeBuild("floret-kit")` in `settings.gradle.kts`) — it is
|
||||
compiled from source, not resolved from a repository. A clone without the
|
||||
submodule fails to configure. For an existing clone:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Android SDK 36+**
|
||||
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. If your
|
||||
default JDK differs, set `JAVA_HOME` explicitly.
|
||||
- **Android SDK** — platform **37** (`compileSdk`) and **build-tools 36.0.0**.
|
||||
`minSdk` is 29, `targetSdk` 36.
|
||||
|
||||
The SDK is located via `ANDROID_HOME` (or `ANDROID_SDK_ROOT`), or via a
|
||||
gitignored `local.properties` with `sdk.dir`. If you use `local.properties`, note
|
||||
that the composite build needs **its own** copy at `floret-kit/local.properties`;
|
||||
setting `ANDROID_HOME` covers both builds at once and is the simpler route.
|
||||
|
||||
The Gradle wrapper is checked in, so you don't need a system Gradle.
|
||||
|
||||
@@ -14,11 +37,44 @@ The Gradle wrapper is checked in, so you don't need a system Gradle.
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
|
||||
./gradlew test # JVM unit tests
|
||||
./gradlew test # JVM unit tests (JUnit 5 + Truth + Turbine)
|
||||
./gradlew lint # Android lint
|
||||
```
|
||||
|
||||
A single test class, or a pattern:
|
||||
|
||||
```bash
|
||||
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
|
||||
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
|
||||
```
|
||||
|
||||
Translation-key invariants (stale and orphaned keys are fatal; missing ones are
|
||||
not) are checked outside Gradle:
|
||||
|
||||
```bash
|
||||
python3 scripts/check_translations.py
|
||||
```
|
||||
|
||||
## What CI runs
|
||||
|
||||
`.forgejo/workflows/ci.yaml` reports a single `CI` check per pull request on
|
||||
Codeberg: the reproducible-release guard, then `lintDebug`,
|
||||
`testDebugUnitTest`, `assembleDebug` and a Trivy filesystem scan. Pull requests
|
||||
that touch only docs, F-Droid metadata or the licence skip the Android build and
|
||||
still report green.
|
||||
|
||||
## Release builds
|
||||
|
||||
Release signing and the publishing pipeline are documented separately in
|
||||
[RELEASING.md](RELEASING.md).
|
||||
[RELEASING.md](RELEASING.md). To smoke-test a release candidate locally, the
|
||||
`releaseTest` build type is an R8-shrunk twin of `release`, debug-signed with its
|
||||
own `applicationId` suffix so it installs alongside the real app:
|
||||
|
||||
```bash
|
||||
scripts/verify-release.sh
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [`../CONTRIBUTING.md`](../CONTRIBUTING.md) for the issue-first workflow,
|
||||
which branch to target, and the architectural rules a change is reviewed against.
|
||||
|
||||
@@ -4,19 +4,22 @@ Where to look for what:
|
||||
|
||||
| Document | What it is |
|
||||
|---|---|
|
||||
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | How to contribute: issue-first workflow, which branch to target, translations, the rules a change is reviewed against |
|
||||
| [`BUILDING.md`](BUILDING.md) | Building from source: submodule, JDK/SDK requirements, Gradle tasks, what CI runs |
|
||||
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Orientation tour: principles, layers, navigation, recurring-write / conflict / reminder pipelines, testing |
|
||||
| [`RELEASING.md`](RELEASING.md) | Release process: versioning, the merge-driven pipeline, the two-forge split, secrets, key custody |
|
||||
| [`../CHANGELOG.md`](../CHANGELOG.md) | Release history (Keep a Changelog, SemVer) |
|
||||
| [`../.planning/ROADMAP.md`](../.planning/ROADMAP.md) | Living roadmap: shipped milestones, current scope, idea backlog |
|
||||
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is, stack, naming, infrastructure |
|
||||
| [`../.planning/REQUIREMENTS.md`](../.planning/REQUIREMENTS.md) | Requirement checklist per milestone |
|
||||
| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands |
|
||||
| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated |
|
||||
| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned |
|
||||
| [Issues](https://codeberg.org/jlmakiola/calendula/issues) + [milestones](https://codeberg.org/jlmakiola/calendula/milestones) | **The roadmap.** What's planned, in progress, and shipped — a milestone maps to its `release/vX.Y.Z` branch |
|
||||
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is: core value, stack + version pins, constraints, naming, forge/release infrastructure |
|
||||
| [`design/`](design/) | Per-feature design notes kept for features whose provider behaviour is worth recording |
|
||||
| [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth): descriptions, title, icon, screenshots (DE + EN). Harvested directly by the official F-Droid repo; transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) |
|
||||
| [`../fdroid-metadata/`](../fdroid-metadata/) | App-level F-Droid control file (`*.yml`: Categories, License, links) for the self-hosted repo's `fdroid update` |
|
||||
| [`fdroid-official/`](fdroid-official/) | Draft recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
|
||||
| [`fdroid-official/`](fdroid-official/) | Recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
|
||||
|
||||
Conventions: plans and specs under `superpowers/` are point-in-time
|
||||
artifacts of the agentic workflow that built each milestone — they get
|
||||
status updates but are never rewritten. The `.planning/` files are living
|
||||
documents and should stay current.
|
||||
Conventions: planning lives in the **issue tracker**, not in this repository. The
|
||||
`.planning/` files that predated it (a roadmap, a development-state snapshot, and
|
||||
a per-milestone requirement checklist) are gone — issues and milestones say the
|
||||
same thing without going stale. `PROJECT.md` is what remains, and it describes
|
||||
the project rather than its plan.
|
||||
`ARCHITECTURE.md` is the authoritative orientation tour: it is updated with the
|
||||
code, and is the right place for a lesson learned about the calendar provider.
|
||||
|
||||
@@ -87,6 +87,9 @@ release work when a merge actually cuts a release:
|
||||
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
||||
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
||||
and do nothing.
|
||||
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to
|
||||
Google Play. Runs last and separately so a Play rejection can't endanger a
|
||||
release that already shipped; skips cleanly until Play is configured.
|
||||
|
||||
### Codeberg direct-download channel
|
||||
|
||||
@@ -102,6 +105,64 @@ unset. One-time setup: the repo's **Releases** unit must be enabled and a
|
||||
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) added
|
||||
to Gitea Actions.
|
||||
|
||||
### Google Play channel
|
||||
|
||||
Play is a third channel alongside F-Droid and the Codeberg download, and it is
|
||||
the only one that gets a different artifact and a different signature.
|
||||
|
||||
**Artifact.** Play takes an **App Bundle** (`bundleRelease`), not the APK. It is
|
||||
a second output of the same source and the same signing config — never a
|
||||
repackage of the published APK, which stays untouched so the F-Droid
|
||||
reproducibility guarantee is unaffected. The bundle is built at the very **end**
|
||||
of the `release` job, after everything else has shipped, and both it and the
|
||||
handoff to the `play` job are `continue-on-error` — nothing Play-related may
|
||||
take down a release that is already published. The handoff uses a patched
|
||||
`upload-artifact`/`download-artifact` fork pinned to a commit: the official v4
|
||||
actions read any non-github.com forge as an unsupported GHES instance and refuse
|
||||
to run on Gitea (go-gitea/gitea#36024). `dependenciesInfo` stays disabled for
|
||||
the bundle too; Play's "app dependencies" report is optional and re-enabling it
|
||||
would break reproducibility.
|
||||
|
||||
**Signature — read this before assuming an update path exists.** Play App
|
||||
Signing is mandatory for new apps, and Google generates and holds the app
|
||||
signing key. The release keystore in CI is registered only as the **upload
|
||||
key**: Play verifies uploads with it, then re-signs with Google's key before
|
||||
delivery. Consequences, accepted deliberately:
|
||||
|
||||
- A Play install and an F-Droid install have **different signatures** and
|
||||
**cannot update each other**. Switching channels requires uninstall +
|
||||
reinstall, which loses nothing (all data lives in the system calendar
|
||||
provider) but must be stated wherever both channels are advertised.
|
||||
- Losing the upload key is **recoverable** — request an upload-key reset in the
|
||||
Play Console. Losing the app key still is not, for F-Droid.
|
||||
|
||||
**Build and signing are not fastlane's job.** Gradle does both, exactly as
|
||||
before. fastlane appears only as the Play Developer API client (`supply`),
|
||||
because the store listing already lives in `fastlane/metadata/android/` — the
|
||||
same tree the official F-Droid repo harvests. One metadata source, two stores.
|
||||
|
||||
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
|
||||
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (generated
|
||||
from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is
|
||||
**not** touched — an accidental overwrite of a live listing triggers a Play
|
||||
policy review. Sync it deliberately with `bundle exec fastlane listing`.
|
||||
|
||||
**Screenshots and graphics are skipped**, because the committed assets satisfy
|
||||
F-Droid but not Play:
|
||||
|
||||
| Asset | Committed | Play requires |
|
||||
| --- | --- | --- |
|
||||
| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha |
|
||||
| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG |
|
||||
| `featureGraphic.png` | *missing* | required, exactly 1024×500 |
|
||||
|
||||
Until those are fixed, Play's graphics are managed by hand in the console. Then
|
||||
pass `images:true` to the `listing` lane.
|
||||
|
||||
**Track.** Uploads go to `internal` by default; promotion to production stays a
|
||||
manual action in the Play Console, matching the rule that UI releases wait for
|
||||
on-device review. Override with the `PLAY_TRACK` repo variable.
|
||||
|
||||
### Manual re-sign / recovery
|
||||
|
||||
A manual `workflow_dispatch` of the release workflow runs a **re-sign-only**
|
||||
@@ -153,6 +214,15 @@ Two consequences worth remembering:
|
||||
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
|
||||
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
|
||||
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console access — uploads the AAB. If unset, the `play` job skips cleanly. |
|
||||
|
||||
### Variables (Gitea → repo Settings → Actions → Variables)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `PLAY_TRACK` | `internal` | Play track the bundle is uploaded to. |
|
||||
| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. |
|
||||
| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit against the API and discards it — use to rehearse. |
|
||||
|
||||
The two keys are independent: the **app key** signs APKs; the **repo key**
|
||||
signs the index (its fingerprint is what users pin). Neither key nor the
|
||||
|
||||
7
fastlane/Appfile
Normal file
7
fastlane/Appfile
Normal file
@@ -0,0 +1,7 @@
|
||||
# fastlane is used in this repo ONLY as a Google Play Developer API client
|
||||
# (see fastlane/Fastfile). It never builds and never signs.
|
||||
|
||||
# Reconstructed in CI from the PLAY_SERVICE_ACCOUNT_JSON secret; gitignored.
|
||||
json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json")
|
||||
|
||||
package_name("de.jeanlucmakiola.calendula")
|
||||
80
fastlane/Fastfile
Normal file
80
fastlane/Fastfile
Normal file
@@ -0,0 +1,80 @@
|
||||
# Google Play publishing only.
|
||||
#
|
||||
# Building and signing are deliberately NOT fastlane's job: release.yaml drives
|
||||
# Gradle directly so the release build stays F-Droid-reproducible (`vcsInfo`,
|
||||
# `dependenciesInfo` and the AGP metadata block are disabled in
|
||||
# app/build.gradle.kts and guarded by scripts/check_reproducible_release.sh).
|
||||
# Interposing fastlane there would add a layer that can inject Gradle flags into
|
||||
# the one build whose byte-for-byte output is verified by a third party.
|
||||
#
|
||||
# What fastlane IS here for is `supply` — the Play Developer API client — because
|
||||
# the store listing already lives in fastlane/metadata/android, which the
|
||||
# official F-Droid repo harvests from the tagged source tree. One metadata tree,
|
||||
# two stores.
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
desc "Upload an already-built, already-signed AAB to Play"
|
||||
lane :deploy do |options|
|
||||
aab = options[:aab] || "app/build/outputs/bundle/release/app-release.aab"
|
||||
UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab)
|
||||
|
||||
supply(
|
||||
aab: aab,
|
||||
track: options[:track] || "internal",
|
||||
release_status: options[:release_status] || "completed",
|
||||
|
||||
# The APK belongs to F-Droid and the Codeberg download; Play only ever
|
||||
# gets the bundle. Never let the uploader reach for the other artifact.
|
||||
skip_upload_apk: true,
|
||||
|
||||
# Listing text is synced deliberately via the `listing` lane, not on every
|
||||
# release. Overwriting a live listing by accident triggers a Play policy
|
||||
# review, which is not a quick revert.
|
||||
skip_upload_metadata: true,
|
||||
|
||||
# "What's New" DOES track every release.
|
||||
# scripts/sync_changelog_to_fastlane.sh writes
|
||||
# fastlane/metadata/android/en-US/changelogs/<versionCode>.txt from
|
||||
# CHANGELOG.md, so Play shows exactly the text F-Droid does.
|
||||
skip_upload_changelogs: false,
|
||||
|
||||
# See the `listing` lane for why the committed images can't go to Play yet.
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true,
|
||||
|
||||
# Dry run: validate the edit against the API and roll it back instead of
|
||||
# committing it. Used for the first end-to-end rehearsal.
|
||||
validate_only: options[:dry_run].to_s == "true",
|
||||
)
|
||||
end
|
||||
|
||||
desc "Sync the store listing text (and, once the assets qualify, the images)"
|
||||
lane :listing do |options|
|
||||
# Deliberate and manual: `bundle exec fastlane listing`. Not wired into the
|
||||
# release pipeline.
|
||||
#
|
||||
# Images stay off by default because the committed assets do not satisfy
|
||||
# Play today, even though F-Droid accepts all of them:
|
||||
#
|
||||
# * phoneScreenshots are 1280x2856. Play caps the long edge at twice the
|
||||
# short edge (2560 here), so every screenshot is rejected.
|
||||
# * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha.
|
||||
# * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG.
|
||||
# * there is no featureGraphic.png. Play requires one, exactly 1024x500.
|
||||
#
|
||||
# Until that is fixed, the Play listing's graphics are managed by hand in the
|
||||
# console. Pass images:true once the assets qualify.
|
||||
upload_images = options[:images].to_s == "true"
|
||||
|
||||
supply(
|
||||
skip_upload_aab: true,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_metadata: false,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: !upload_images,
|
||||
skip_upload_screenshots: !upload_images,
|
||||
)
|
||||
end
|
||||
end
|
||||
128
fastlane/metadata/android/en-US/changelogs/21701.txt
Normal file
128
fastlane/metadata/android/en-US/changelogs/21701.txt
Normal file
@@ -0,0 +1,128 @@
|
||||
### Added
|
||||
- Settings → Calendars now says what is different about a calendar instead of
|
||||
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||
calendar shared with you read-only — are marked **Read-only** ([#76]).
|
||||
- Calendars your device isn't syncing are marked **Not synced**, moved to the
|
||||
bottom of their account and left without a switch. None of their events are on
|
||||
the device, so the switch they used to have could not have shown you anything
|
||||
— the calendar simply looked broken. They are no longer offered when you pick
|
||||
a calendar for a new or an imported event either: an event saved there would
|
||||
never reach the account. Whether an account syncs a calendar stays that
|
||||
account's own app's decision ([#78]).
|
||||
- The birthday and anniversary calendars Calendula fills from your contacts are
|
||||
marked **Filled from your contacts**, which is why they can't be picked for a
|
||||
new event: anything you put there would be removed again on the next sync.
|
||||
Deleting one is held back while special dates are switched on — Calendula
|
||||
would simply create it again — and the calendar's editor says so; turn the
|
||||
feature off under Settings → Special dates and the delete works as usual
|
||||
([#76]).
|
||||
- The calendar picker in the event form and in the .ics import screen now ends
|
||||
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||
those marks then explain why a calendar isn't offered ([#76]).
|
||||
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
|
||||
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||
the widget used to make from its own measurements. Small is what it looks like
|
||||
today, so nothing changes until you turn it up ([#51]).
|
||||
- A repeating event now shows you its next few dates, not just a description of
|
||||
the rule. Both the preset list and the custom recurrence picker carry a
|
||||
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
|
||||
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
|
||||
can mean something other than it sounds like, and only the dates say so. A
|
||||
rule that can never fire says that instead ([#69]).
|
||||
- The event visibility options now say who they affect. **Public**, **Private**
|
||||
and **Confidential** each carry a line about what other people on a shared
|
||||
calendar see — the part the four words on their own leave out ([#69]).
|
||||
|
||||
### Changed
|
||||
- **Settings has been reorganised so each setting sits where you would look for
|
||||
it.** One long undifferentiated list is now three labelled groups — Look &
|
||||
behaviour, Data, and App — whose rows open sub-screens and say what they do
|
||||
rather than only naming themselves. Appearance, Views, New event form,
|
||||
Notifications and the new Widgets & tiles are separate screens now, so the
|
||||
settings for a calendar view are no longer mixed in with the ones for the
|
||||
app's colours or for the home-screen widgets ([#69]).
|
||||
- Settings that are hard to picture from their name now show you what they do.
|
||||
The week-start picker rearranges a real month grid as you choose, the
|
||||
past-events setting previews a sample agenda day for Show, Dim and Hide, the
|
||||
font pickers set a specimen line in the face you are choosing, and the Agenda
|
||||
range options carry the dates each one actually covers. Options that follow
|
||||
the system additionally name which way they currently fall ([#69]).
|
||||
- **Backup & restore** is now its own Settings entry instead of living inside
|
||||
the calendar manager, where it was easy to miss — keeping a copy of your
|
||||
calendars is a different question from which calendars you have. The calendar
|
||||
manager keeps a row pointing to it, and nothing about how backup or automatic
|
||||
backup works has changed ([#69]).
|
||||
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||
were. The **Source code** and **License** links in Settings → About point
|
||||
there, so reporting a bug and reading the code no longer land on two different
|
||||
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||
unaffected.
|
||||
|
||||
### Fixed
|
||||
- A month-grid widget stays a month grid, and draws all seven days again. Since
|
||||
2.16.0 a placed month widget could redraw itself as the agenda widget a little
|
||||
after any change to your events, and could draw only about four day columns
|
||||
with the last one cut off part-way through. Both came from the release build
|
||||
merging the two widgets into a single class, so Android could no longer tell
|
||||
which of them a widget on your home screen was — and the month grid was handed
|
||||
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
|
||||
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||
did the same ([#81]).
|
||||
- The dots standing in for the events that didn't fit a day in the month view
|
||||
now dim with everything else when **Dim completed events** is on. A past day
|
||||
with four or more events kept its last events at full strength while the rest
|
||||
faded ([#79]).
|
||||
- Two accounts that happen to share a name — a Google account and a DAVx5
|
||||
account for the same address, say — are no longer merged into one group.
|
||||
They were listed together in Settings → Calendars and in the drawer's filter,
|
||||
which also meant the group's source icon and its "manage in app" button could
|
||||
send you to the wrong app, "toggle all" spanned both accounts at once, and
|
||||
collapsing one collapsed the other. Where a name really is shared, each group
|
||||
now names the app it comes from ([#77]).
|
||||
- In the month view's **Split** style, the new-event button now starts on the
|
||||
day you have selected. It always started on today, whichever day was selected
|
||||
and listed below the grid ([#87]).
|
||||
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||
day the month, week and agenda views file the same event under ([#82]).
|
||||
- Reminders no longer depend on Android telling Calendula when they are due.
|
||||
Calendula now works out each reminder's time itself and sets its own alarm for
|
||||
it. On some phones — Samsung's among them — the system's calendar storage never
|
||||
sends the signal a calendar app is meant to wake up on, and no amount of
|
||||
battery or notification settings helps: the reminder is simply never announced.
|
||||
None of that is visible from inside an app that waits to be told, which is why
|
||||
it took a second pass to find ([#75]).
|
||||
|
||||
Reminders also survive things that used to lose them quietly. After a restart
|
||||
or an app update Calendula re-arms its alarms, and a reminder whose moment
|
||||
passed while the phone was off still arrives, as long as the event has not
|
||||
ended yet.
|
||||
|
||||
- All-day reminders now arrive at the time you chose in **Settings →
|
||||
Notifications**, on every occurrence. A yearly birthday could drift an hour
|
||||
either way depending on daylight saving, and all-day reminders on calendars
|
||||
from an account fired in the middle of the night instead of in the morning
|
||||
([#75]).
|
||||
|
||||
- Reminders now arrive for every calendar you have switched on. A calendar that
|
||||
was hidden at system level — switched off in another calendar app, or never
|
||||
switched on after being added — still showed its events and listed their
|
||||
reminders in Calendula, but never notified: Android only schedules reminder
|
||||
alarms for calendars marked visible, and Calendula kept its own separate
|
||||
on/off list that had no say in it. There is now one switch: **Settings →
|
||||
Calendars** turns a calendar on or off for the whole device, so what you see
|
||||
and what reminds you can no longer disagree ([#75]).
|
||||
|
||||
Calendars you had switched off in Calendula are switched off here too on first
|
||||
launch. Calendars that were already off — hidden in another calendar app, or
|
||||
never switched on after being added — stay off, and Calendula says so once
|
||||
rather than quietly switching them on for every app on your device; you can
|
||||
turn any of them back on in Settings → Calendars.
|
||||
|
||||
If you gave Calendula read-only access to your calendars, the switch still
|
||||
works: your choice is kept in the app until it can be written.
|
||||
|
||||
The drawer's filter is unchanged and still app-only: hiding a calendar there
|
||||
tidies your view without silencing its reminders.
|
||||
|
||||
@@ -7,20 +7,20 @@ coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
lifecycleRuntime = "2.10.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
composeBom = "2026.06.01"
|
||||
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
|
||||
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
||||
# Re-evaluate when 1.5.0 stable lands.
|
||||
material3 = "1.5.0-alpha21"
|
||||
material3 = "1.5.0-alpha24"
|
||||
datastore = "1.2.1"
|
||||
junit = "6.1.0"
|
||||
junitPlatform = "6.1.0"
|
||||
junit = "6.1.2"
|
||||
junitPlatform = "6.1.2"
|
||||
truth = "1.4.5"
|
||||
androidxJunit = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
kotlinxDatetime = "0.7.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
turbine = "1.2.0"
|
||||
turbine = "1.2.1"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
lifecycleCompose = "2.10.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
|
||||
Reference in New Issue
Block a user