Compare commits
1 Commits
v2.17.1
...
renovate/k
| Author | SHA1 | Date | |
|---|---|---|---|
| 138501397b |
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Something doesn't work the way it should
|
|
||||||
title: ""
|
|
||||||
labels:
|
|
||||||
- bug
|
|
||||||
---
|
|
||||||
|
|
||||||
### What happened
|
|
||||||
|
|
||||||
|
|
||||||
### What you expected
|
|
||||||
|
|
||||||
|
|
||||||
### Steps to reproduce
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
|
||||||
- Android version:
|
|
||||||
- Device:
|
|
||||||
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
|
|
||||||
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
|
||||||
subscribed/WebCal, birthdays — provider behaviour differs
|
|
||||||
a lot per account type, so this often points straight at
|
|
||||||
the cause -->
|
|
||||||
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Kept enabled so anything that doesn't fit the four templates still has a way
|
|
||||||
# in (the `ToDo` label exists for exactly those).
|
|
||||||
blank_issues_enabled: true
|
|
||||||
|
|
||||||
contact_links:
|
|
||||||
- name: Translate Calendula
|
|
||||||
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
|
||||||
about: >-
|
|
||||||
Translations are managed on Weblate, not here — it owns every values-*
|
|
||||||
file, so a hand-edited translation gets overwritten on the next sync.
|
|
||||||
No coding needed: pick or request a language and translate in the browser.
|
|
||||||
|
|
||||||
- name: Contributing guide
|
|
||||||
url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
|
||||||
about: >-
|
|
||||||
Before opening a pull request: the issue-first workflow, which release
|
|
||||||
branch to target, how to build (there's a submodule), and the
|
|
||||||
architectural rules a change is reviewed against.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!--
|
|
||||||
Thanks for contributing to Calendula!
|
|
||||||
|
|
||||||
Please skim CONTRIBUTING.md if you haven't:
|
|
||||||
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
|
||||||
|
|
||||||
Two things it's easy to get wrong:
|
|
||||||
• Features need a discussed issue first — an undiscussed feature PR may be
|
|
||||||
closed unmerged even when the code is good.
|
|
||||||
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
|
||||||
release/v2.18.0), not main. If you targeted main, just say so below and it
|
|
||||||
will be retargeted.
|
|
||||||
-->
|
|
||||||
|
|
||||||
### What this changes
|
|
||||||
|
|
||||||
|
|
||||||
### Why
|
|
||||||
|
|
||||||
<!-- Closes #123 — link the issue this implements or fixes. -->
|
|
||||||
|
|
||||||
|
|
||||||
### How it was tested
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Which of these ran green, and anything you exercised by hand. On-device notes
|
|
||||||
are especially useful for UI changes.
|
|
||||||
|
|
||||||
./gradlew lint test assembleDebug
|
|
||||||
python3 scripts/check_translations.py
|
|
||||||
-->
|
|
||||||
|
|
||||||
|
|
||||||
### Checklist
|
|
||||||
|
|
||||||
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
|
||||||
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
|
|
||||||
- [ ] `./gradlew lint test assembleDebug` passes locally
|
|
||||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
|
||||||
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
|
||||||
- [ ] No planning or design documents committed
|
|
||||||
@@ -37,29 +37,14 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
# Decide whether anything that affects the app build changed. Docs, store
|
# Decide whether anything that affects the app build changed. Docs,
|
||||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
||||||
# the SDK + Gradle work below but still report a green `ci`.
|
# Gradle work below but still report a green `ci`.
|
||||||
- name: Classify change scope
|
- name: Classify change scope
|
||||||
id: 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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BASE="${{ github.base_ref }}"
|
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
|
# 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
|
# the PR branch forked several commits back; a shallow tip has no merge
|
||||||
# base with a divergent branch and `git diff base...HEAD` aborts.
|
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||||
@@ -73,15 +58,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
CHANGED=$(git diff --name-only "$MB" HEAD)
|
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||||
echo "Changed files:"; echo "$CHANGED"
|
echo "Changed files:"; echo "$CHANGED"
|
||||||
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
|
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
||||||
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"
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "Docs/metadata-only change — skipping the Android build."
|
|
||||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Docs/metadata-only change — skipping the Android build."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
|
|||||||
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something doesn't work the way it should
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
---
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### What you expected
|
||||||
|
|
||||||
|
|
||||||
|
### Steps to reproduce
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
@@ -5,7 +5,7 @@ title: "Crash: "
|
|||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- crash
|
- crash
|
||||||
- priority/high
|
- priority:high
|
||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
name: Release — F-Droid repo + Gitea/Codeberg release
|
||||||
|
|
||||||
# A release is cut by merging a release branch into main with a bumped
|
# 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,
|
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||||
@@ -9,12 +9,6 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
|||||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing.
|
# 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
|
# 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,
|
# 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
|
# without building an APK or creating a release. Used for key rotation / repo
|
||||||
@@ -483,168 +477,3 @@ jobs:
|
|||||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||||
done
|
done
|
||||||
echo "Published $TAG to Codeberg."
|
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,27 +50,11 @@ google-services.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Editor swap/backup files
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# F-Droid local artifacts (the pipeline generates them in CI)
|
# F-Droid local artifacts (the pipeline generates them in CI)
|
||||||
/fdroid/
|
/fdroid/
|
||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.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 Code
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
|
|||||||
## Current Milestone
|
## Current Milestone
|
||||||
|
|
||||||
Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder
|
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. Everything since is
|
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0
|
||||||
tracked as issues and milestones on Codeberg:
|
(power-user features) plus an undecided "Locations & People" idea backlog;
|
||||||
<https://codeberg.org/jlmakiola/calendula/milestones>. A milestone maps to its
|
see `ROADMAP.md`.
|
||||||
`release/vX.Y.Z` branch.
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -28,26 +27,10 @@ Kotlin 2.3.21 (paired with KSP 2.3.9 — Kotlin 2.4.0 has no KSP release
|
|||||||
yet, do not upgrade until one ships). Jetpack Compose + Material 3
|
yet, do not upgrade until one ships). Jetpack Compose + Material 3
|
||||||
Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
|
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
|
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
|
||||||
with Version Catalog. AGP 9.2.1, Gradle 9.5.1. JVM target 17 (exactly — AGP
|
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
|
||||||
requires it).
|
|
||||||
|
|
||||||
The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired
|
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
|
||||||
in as a Gradle composite build, so it compiles from source rather than resolving
|
any feature that would need one is an explicit product decision first.
|
||||||
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
|
## Naming
|
||||||
|
|
||||||
@@ -57,9 +40,5 @@ shows a stylized "1" on a slate squircle.
|
|||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
|
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
|
||||||
releases, plus contributor CI. The self-hosted Gitea instance is build
|
Hetzner. Same infrastructure as `HouseHoldKeaper`.
|
||||||
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`.
|
|
||||||
|
|||||||
53
.planning/REQUIREMENTS.md
Normal file
53
.planning/REQUIREMENTS.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Calendula — Requirements
|
||||||
|
|
||||||
|
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
||||||
|
|
||||||
|
## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
|
||||||
|
|
||||||
|
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
|
||||||
|
- [x] Data Layer over `CalendarContract`
|
||||||
|
- [x] Permission flow (`READ_CALENDAR`)
|
||||||
|
- [x] Month view (S1)
|
||||||
|
- [x] Week view (S2)
|
||||||
|
- [x] Day view (S3)
|
||||||
|
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
|
||||||
|
- [x] Multi-Calendar Filter (M3)
|
||||||
|
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
||||||
|
- [x] View-Switcher (M1)
|
||||||
|
- [x] Settings screen (M4)
|
||||||
|
- [x] Empty / no-permission / no-calendars states
|
||||||
|
- [x] German + English localization
|
||||||
|
- [x] Loading/Failure/Success states per screen (architectural pattern)
|
||||||
|
|
||||||
|
## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
|
||||||
|
|
||||||
|
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
|
||||||
|
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
|
||||||
|
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
|
||||||
|
- [x] Reminder notifications (v1.4) — **reversal of the original
|
||||||
|
"system handles reminders" assumption:** Calendula targets
|
||||||
|
sole-calendar-app users, so it posts reminder notifications itself
|
||||||
|
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
|
||||||
|
- [x] Conflict dialog on save + store polish (v2.0)
|
||||||
|
- Quick-add — **cut from scope** (the prefilled form covers it)
|
||||||
|
- Calendar switching while editing — moved to v3 backlog
|
||||||
|
|
||||||
|
### Out of Scope (V3+)
|
||||||
|
|
||||||
|
- Home-screen widget
|
||||||
|
- Full-text search
|
||||||
|
- Tablet/foldable-specific layouts
|
||||||
|
- Locations & People ideas (contact picker, OSM autocomplete) — see
|
||||||
|
`ROADMAP.md` idea backlog, undecided
|
||||||
|
- iOS support (Android-only by design)
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore
|
||||||
|
- **Tech stack pin:** Hilt 2.59.2 + KSP 2.3.9; Kotlin 2.3.21 (KSP for Kotlin 2.4.0 not released yet). Material 3 pinned to `1.5.0-alpha21` (Expressive APIs only exist in alpha). Re-evaluate when KSP/Material3 stable land.
|
||||||
|
- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target
|
||||||
|
- **Offline-first:** all data lives in `CalendarContract`; no app-side network
|
||||||
|
- **Privacy:** zero telemetry, no analytics
|
||||||
|
- **i18n:** German + English from day one
|
||||||
|
- **Tests + CI from day one**
|
||||||
|
- **License:** MIT
|
||||||
587
.planning/ROADMAP.md
Normal file
587
.planning/ROADMAP.md
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
# Calendula — Roadmap
|
||||||
|
|
||||||
|
## v0.x — Pre-Release
|
||||||
|
|
||||||
|
| Version | Milestone | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v0.1 | Foundation & CI | complete |
|
||||||
|
| v0.2 | Data Layer & Permission Flow | complete |
|
||||||
|
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||||
|
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||||
|
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||||
|
| v0.6 | Full event read — surface every readable field | complete |
|
||||||
|
| v1.0 | First public release — polish pass, F-Droid | complete |
|
||||||
|
|
||||||
|
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||||
|
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||||
|
|
||||||
|
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
|
||||||
|
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
|
||||||
|
|
||||||
|
## v0.6 — Full event read
|
||||||
|
|
||||||
|
Round out the read-only model so a detail view shows everything the system
|
||||||
|
actually stores, before write support starts. Scope = `CalendarContract`
|
||||||
|
columns we don't yet read/display:
|
||||||
|
|
||||||
|
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
|
||||||
|
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
|
||||||
|
- **Availability** (`TRANSP`) — Free / Busy chip
|
||||||
|
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
||||||
|
`SELF_ATTENDEE_STATUS`
|
||||||
|
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
||||||
|
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
||||||
|
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
||||||
|
URLs are instead surfaced by linkifying the description text
|
||||||
|
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
||||||
|
|
||||||
|
All of the above shipped in v0.6.0 (2026-06-11).
|
||||||
|
|
||||||
|
Deliberately out of v0.6:
|
||||||
|
- Recurrence exception / modified-occurrence badges — `Instances` already
|
||||||
|
resolves correct per-occurrence times for display; this only matters for
|
||||||
|
editing, so it folds into v2
|
||||||
|
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
||||||
|
(provider limitation, not our choice)
|
||||||
|
|
||||||
|
## v1.0 — First Public Release — shipped 2026-06-11
|
||||||
|
|
||||||
|
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
|
||||||
|
after v0.6 (full event read) plus the onboarding-screen polish pass.
|
||||||
|
|
||||||
|
### Polish backlog (pre-1.0)
|
||||||
|
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||||
|
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||||
|
|
||||||
|
## v2.0 — Write Support (complete, shipped 2026-06-11)
|
||||||
|
|
||||||
|
Delivered in four releasable slices (plan:
|
||||||
|
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||||
|
guide here, not a contract — scope per slice is decided as we go.
|
||||||
|
|
||||||
|
| Version | Milestone | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
||||||
|
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||||
|
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||||
|
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||||
|
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
||||||
|
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
|
||||||
|
|
||||||
|
v2.0 scope was re-cut on 2026-06-11, after v1.4:
|
||||||
|
- **Occurrence edit** already shipped early, in v1.3.
|
||||||
|
- **Quick-add** is **cut from scope**: the full form already opens prefilled
|
||||||
|
(visible day, last-used calendar, optional fields hidden), so the sheet
|
||||||
|
would only save one screen transition while adding a second create-surface
|
||||||
|
to maintain. Revisit only if real-world feedback says creation feels heavy.
|
||||||
|
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
|
||||||
|
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
|
||||||
|
honest implementation is copy+delete like Google Calendar, with sync-identity
|
||||||
|
and attendee side effects).
|
||||||
|
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
|
||||||
|
the row as it was when the form loaded; on external change, ask
|
||||||
|
overwrite / discard. Closes the silent-clobber gap on synced calendars.
|
||||||
|
|
||||||
|
## v1.4 — Reminder Notifications
|
||||||
|
|
||||||
|
**Essential**, not nice-to-have: Calendula targets users for whom it is their
|
||||||
|
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
|
||||||
|
Calendar. The calendar provider schedules reminders and broadcasts
|
||||||
|
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
|
||||||
|
notification — a calendar app must. We become that app (the Etar model).
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
|
||||||
|
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
|
||||||
|
no foreground service.
|
||||||
|
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
|
||||||
|
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
|
||||||
|
notification channel; tap opens event detail.
|
||||||
|
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
|
||||||
|
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
|
||||||
|
toggle, **default ON**, with copy warning that a second calendar app with
|
||||||
|
notifications on will cause duplicate reminders. Mirrored into Settings
|
||||||
|
(reversible).
|
||||||
|
|
||||||
|
Deliberately deferred (add only if needed):
|
||||||
|
- Snooze / dismiss notification actions (Etar has them)
|
||||||
|
- Battery-optimization exemption prompt for delivery reliability
|
||||||
|
|
||||||
|
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
|
||||||
|
|
||||||
|
- Month grid shows real events as continuous multi-day bars (not just dots)
|
||||||
|
- View section in the navigation drawer to switch Month / Week / Day
|
||||||
|
- Fix: text cursor no longer jumps in event text fields
|
||||||
|
|
||||||
|
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
|
||||||
|
|
||||||
|
- Tap an empty slot in day/week → create form prefilled with that day + the
|
||||||
|
tapped hour (snapped to the hour, 1 h long)
|
||||||
|
- Local (device-only) calendar management in a full-screen editor from
|
||||||
|
Settings → Calendars: create / rename / recolor / delete, with name,
|
||||||
|
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
|
||||||
|
- Synced calendars listed read-only, grouped by account, each with a
|
||||||
|
per-account "manage in source app" deep-link (resolved from the account's
|
||||||
|
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
|
||||||
|
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
|
||||||
|
editor share one input style)
|
||||||
|
|
||||||
|
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
|
||||||
|
|
||||||
|
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
|
||||||
|
gallery app) across Settings, the calendar manager and the navigation drawer.
|
||||||
|
|
||||||
|
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
|
||||||
|
whose title collapses on scroll) + `GroupedRow` (Position-based corner
|
||||||
|
grouping, press-animated corners, `selected` + `minHeight` knobs).
|
||||||
|
- Settings: category hub with About card on top and sliding sub-pages
|
||||||
|
(Appearance / New event form / Notifications); theme/week-start/language
|
||||||
|
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
|
||||||
|
chips; `ic_gitea.xml` for the About "Source" button.
|
||||||
|
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
|
||||||
|
drawer scrolls as one with the active view highlighted.
|
||||||
|
- Cards use `surfaceContainerHigh` for readable contrast.
|
||||||
|
- Donate button on the About card deferred (target TBD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backlog (theme-based, post-v2.1)
|
||||||
|
|
||||||
|
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
|
||||||
|
consolidated here by theme. Within a group, **(in progress)** /
|
||||||
|
**(next)** mark what is being or about to be worked; everything else is an
|
||||||
|
approved-but-unscheduled idea unless tagged **(idea)** /
|
||||||
|
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
|
||||||
|
|
||||||
|
## Near-term sequence (ranked, 2026-06-16)
|
||||||
|
|
||||||
|
The theme groups below are the full menu; this is the committed *order* for
|
||||||
|
the next stretch. Ranking favours finishing the current create/edit + calendar
|
||||||
|
arc before opening new fronts, then cheap-relative-to-value items and ones that
|
||||||
|
unblock a later item. Order is a plan, not a contract — revisit after each lands.
|
||||||
|
|
||||||
|
**Tier 1 — finish the current arc (create/edit + calendars)**
|
||||||
|
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
|
||||||
|
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
|
||||||
|
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
||||||
|
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
||||||
|
above)*
|
||||||
|
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||||
|
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
||||||
|
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
||||||
|
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
|
||||||
|
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
|
||||||
|
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
|
||||||
|
|
||||||
|
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||||
|
|
||||||
|
### Settings redesign & restructure *(shipped v2.3.0)*
|
||||||
|
|
||||||
|
The original scope below is kept as a record; the implementation expanded from a
|
||||||
|
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
|
||||||
|
|
||||||
|
The settings screen has grown into a flat vertical scroll of divider-separated
|
||||||
|
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
|
||||||
|
will keep accreting rows (per-event-color defaults, default reminder, more
|
||||||
|
calendar entries are all queued). It needs structure before it gets unwieldy.
|
||||||
|
|
||||||
|
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
|
||||||
|
becomes a category list; each category opens its own destination. More
|
||||||
|
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
|
||||||
|
existing Calendars row, which already navigates out to its own screen.
|
||||||
|
|
||||||
|
Structure — top-level settings list → category destinations:
|
||||||
|
- **Appearance** → theme, dynamic colour, week start
|
||||||
|
- **Event form** → the 6 default-field toggles + the hint text
|
||||||
|
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
|
||||||
|
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
|
||||||
|
peer category row, no change to that screen
|
||||||
|
- **Language** → single control; keep as a top-level row that opens an
|
||||||
|
OptionCard directly (a whole sub-screen for one choice is overkill)
|
||||||
|
- **About** → kept inline on the top-level list as a card (read-only info,
|
||||||
|
not worth a navigation hop). Card layout, top → bottom:
|
||||||
|
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
|
||||||
|
as a subtitle beneath the name
|
||||||
|
- **Action buttons** (small, button-styled, sit in a row):
|
||||||
|
- **Source** — Gitea logo, opens the repo (`about_source_url`)
|
||||||
|
- **License** — opens the LICENSE file on Gitea
|
||||||
|
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
|
||||||
|
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
|
||||||
|
- **Version** — small version number at the bottom of the card
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- **Navigation** — add the settings sub-screen destinations alongside the
|
||||||
|
existing settings/calendars routes in `CalendarHost`; back pops to the
|
||||||
|
settings list (mind the existing `BackHandler` that guards against falling
|
||||||
|
through to the activity).
|
||||||
|
- **Fix the dialog-pattern violation** — theme, week-start and language use
|
||||||
|
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
|
||||||
|
(radio/dropdown/text-list dialogs are banned, see
|
||||||
|
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
|
||||||
|
- **Visual pass** — top-level category rows with leading icons; consistent
|
||||||
|
spacing and row affordances aligned with the event-form card design system.
|
||||||
|
|
||||||
|
Out of scope (no new settings *features* here) — this is a structure + style
|
||||||
|
pass on the existing controls; new toggles ride in with their own features.
|
||||||
|
|
||||||
|
**Tier 2 — navigation & daily-driver completeness**
|
||||||
|
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
|
||||||
|
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
|
||||||
|
|
||||||
|
**Tier 3 — platform reach (depends on Tier 2)**
|
||||||
|
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
||||||
|
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
|
||||||
|
|
||||||
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
|
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||||
|
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||||
|
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||||
|
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
|
||||||
|
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||||
|
four surfaces all shipped: single-event share + whole-calendar backup
|
||||||
|
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
|
||||||
|
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
|
||||||
|
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
|
||||||
|
then `feat/ics-import` (parser, restore, dedup by UID). Import is
|
||||||
|
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
|
||||||
|
/ guest lists it can't model. Plans:
|
||||||
|
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
|
||||||
|
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* —
|
||||||
|
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
|
||||||
|
decision (snooze must re-fire an alarm).
|
||||||
|
|
||||||
|
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
||||||
|
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
|
||||||
|
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
|
||||||
|
the event model: attendees were already *read* (queried, mapped, shown on the
|
||||||
|
detail screen since v0.6) and are now *writable* from the form. An attendees
|
||||||
|
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
|
||||||
|
no-permission contact picker, edits/removes rows, and sets role (required /
|
||||||
|
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
|
||||||
|
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
|
||||||
|
caveat was **settled record-only** (Calendula never sends invites; the backend
|
||||||
|
decides delivery) — full sketch + decision in "Attendee editing" under Locations
|
||||||
|
& People below.
|
||||||
|
|
||||||
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
|
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||||
|
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
|
||||||
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
|
**Bottom — deprioritised, not important**
|
||||||
|
- Duplicate event (detail action → prefilled create form) — moved here
|
||||||
|
2026-06-17; cheap but low value, pick up only if asked
|
||||||
|
|
||||||
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
|
||||||
|
Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
|
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||||
|
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
|
||||||
|
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
|
||||||
|
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
|
||||||
|
full-text search, the "New event" Quick Settings tile, and the now-line all
|
||||||
|
shipped there. **No tier work is currently committed as next;** the remaining
|
||||||
|
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
|
||||||
|
accessibility pass) plus the gated go/no-go items.
|
||||||
|
|
||||||
|
## Navigation & views
|
||||||
|
|
||||||
|
- ~~Tap an empty slot in day/week → create form prefilled with that
|
||||||
|
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
|
||||||
|
not added — single tap covers it)
|
||||||
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
|
natural data source for a future widget)
|
||||||
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
|
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
||||||
|
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
||||||
|
the view and shrinks the day cells; the badge stays week-view-only.
|
||||||
|
- Pinch-to-zoom time scale in day/week
|
||||||
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
|
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
|
||||||
|
history, finding an event is core completeness, not optional.
|
||||||
|
|
||||||
|
## Event editing & creation
|
||||||
|
|
||||||
|
- Duplicate event (detail action → prefilled create form)
|
||||||
|
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
||||||
|
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
||||||
|
picker in the form, falling back to the calendar color when unset. Reused the
|
||||||
|
color-picker component and palette plumbing from local calendar management and
|
||||||
|
finished the create/edit theme.
|
||||||
|
|
||||||
|
## Calendars & accounts
|
||||||
|
|
||||||
|
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0** —
|
||||||
|
name + color + description; rename / recolor / delete the calendars the app
|
||||||
|
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
|
||||||
|
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
|
||||||
|
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
|
||||||
|
synced calendars, open the app the calendar actually came from based on
|
||||||
|
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
|
||||||
|
…); fall back to system account/sync settings. Plus an "add account"
|
||||||
|
entry into system Accounts. Honest boundary for remote calendars.
|
||||||
|
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
|
||||||
|
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
|
||||||
|
client: **INTERNET permission, credential storage, the full server
|
||||||
|
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
|
||||||
|
intent to delegate the create to it. Cosmetic local edits (color/name)
|
||||||
|
to an existing synced row are possible but don't propagate to the server
|
||||||
|
and may be overwritten on next sync — not promised. Same explicit
|
||||||
|
go/no-go gate as the OSM/INTERNET item below.
|
||||||
|
- Move event to another calendar (copy+delete model with a consequences
|
||||||
|
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||||
|
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** —
|
||||||
|
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
|
||||||
|
backup. Settings → Calendars → Export writes every event to a user-chosen
|
||||||
|
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||||
|
UID). Closed the silent data-loss gap.
|
||||||
|
|
||||||
|
### Disable a calendar in-app *(captured 2026-06-25)*
|
||||||
|
|
||||||
|
A second, heavier visibility level **above** the existing per-view filter. Today
|
||||||
|
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
||||||
|
a calendar's *events* from the month/week/day/agenda views — the calendar itself
|
||||||
|
still clutters the drawer filter list, the event-form calendar picker, and the
|
||||||
|
import target picker. "Disable" removes a calendar from the app's surfaces
|
||||||
|
entirely; "hide" stays the lightweight, frequently-toggled control.
|
||||||
|
|
||||||
|
**Two-level model (both kept):**
|
||||||
|
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
|
||||||
|
drawer. Toggles events on/off in the views; the calendar stays listed
|
||||||
|
everywhere. Operates only over the *enabled* calendars.
|
||||||
|
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
|
||||||
|
filter list, not in the event-form picker, not in the import picker, and its
|
||||||
|
events never appear (it's gated out of `instances()` like a hidden one). It
|
||||||
|
remains visible **only** in Settings → Calendars, where the enable/disable
|
||||||
|
toggle lives, so it can be brought back.
|
||||||
|
|
||||||
|
**Storage — app-side (DataStore), mirrors the hidden set.** Add
|
||||||
|
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
|
||||||
|
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
|
||||||
|
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
|
||||||
|
a Calendula-local preference, so other calendar apps are unaffected and the sync
|
||||||
|
adapters stay out of it (privacy-clean, reversible).
|
||||||
|
|
||||||
|
**Where the disabled set is applied:**
|
||||||
|
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
|
||||||
|
`calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show
|
||||||
|
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
|
||||||
|
the screens that need everything still get everything.)
|
||||||
|
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
|
||||||
|
(you can't hide/show what's disabled).
|
||||||
|
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
|
||||||
|
into a calendar you've removed from the app. Handle the last-used-calendar
|
||||||
|
preselect falling on a now-disabled calendar (fall back to first enabled
|
||||||
|
writable).
|
||||||
|
- `ImportViewModel` — exclude disabled from the import target list.
|
||||||
|
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
|
||||||
|
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
|
||||||
|
|
||||||
|
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
|
||||||
|
row on the existing `CalendarsScreen` (both the local and the synced/read-only
|
||||||
|
groups — disabling is an app-side view choice, independent of write access).
|
||||||
|
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
|
||||||
|
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
|
||||||
|
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
|
||||||
|
|
||||||
|
**Decided behaviour:**
|
||||||
|
- All calendars disabled → views show the existing empty state.
|
||||||
|
- A disabled calendar that still holds events: events simply vanish from
|
||||||
|
views/search until re-enabled (no data touched — it's a filter, not a delete).
|
||||||
|
- Deep links / notifications pointing at an event in a disabled calendar still
|
||||||
|
open its detail screen — detail is a direct id lookup, not an `instances()`
|
||||||
|
query, so disabling never strands an existing link.
|
||||||
|
|
||||||
|
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
|
||||||
|
|
||||||
|
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||||
|
the "Calendula is your only calendar app" promise.
|
||||||
|
|
||||||
|
**Built in this slice (A + the safe half of B):** global timed default reminder
|
||||||
|
+ a **separate all-day default** (day-scale lead times) + per-calendar override
|
||||||
|
(timed events), applied on create with manual-edit / calendar-switch / all-day-
|
||||||
|
toggle handling; three pickers + per-calendar override list in Settings →
|
||||||
|
Notifications; battery-optimisation exemption row (status + system deep-link, no
|
||||||
|
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
|
||||||
|
Resolution model: all-day events use the all-day global default outright;
|
||||||
|
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
|
||||||
|
applied: form-reset state race, label-fn consolidation with the detail screen,
|
||||||
|
inline wrapper + single combined flow read.
|
||||||
|
|
||||||
|
**Deliberately deferred (documented decisions, not oversights):**
|
||||||
|
- *Absolute time-of-day for all-day reminders* — the all-day default is still
|
||||||
|
minutes-before-midnight (day-scale presets), not "9am the day before" (open
|
||||||
|
decision #2's richer half). Per-calendar all-day overrides also deferred.
|
||||||
|
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
|
||||||
|
(open decision #1). The battery exemption is the reliability lever; no
|
||||||
|
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
|
||||||
|
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
|
||||||
|
exemption lives only in Settings for now (onboarding flow untouched to keep
|
||||||
|
the change reviewable).
|
||||||
|
|
||||||
|
### A. Default reminders (global + per-calendar override)
|
||||||
|
|
||||||
|
**No provider backing.** `CalendarContract` has no column that auto-applies a
|
||||||
|
default reminder per calendar — Google's per-calendar defaults live server-side.
|
||||||
|
So both the global default *and* the per-calendar override are **app-side
|
||||||
|
preferences**, applied by us at event-insert time. We inherit nothing from the
|
||||||
|
synced calendar.
|
||||||
|
|
||||||
|
- **Storage (DataStore):**
|
||||||
|
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
|
||||||
|
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
|
||||||
|
reminders are expressed as minutes before midnight / day-before-at-time, not
|
||||||
|
minutes before a start instant — they need their own value).
|
||||||
|
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
|
||||||
|
**absent key = inherit global**, explicit `null` = "no reminder for this
|
||||||
|
calendar". (Same for an all-day override map if we want per-calendar all-day.)
|
||||||
|
- **Apply on create:** a fresh event prefills its reminders list from
|
||||||
|
override-or-global for the preselected calendar. Changing the calendar in the
|
||||||
|
form re-applies the *new* calendar's default **only if the user hasn't manually
|
||||||
|
edited the reminders** — track a dirty flag, mirroring the per-event-color
|
||||||
|
reset pattern (v2.4).
|
||||||
|
- **Edit semantics:** defaults apply to **new events only**; never rewrite
|
||||||
|
reminders on existing events on open or on calendar-switch-during-edit.
|
||||||
|
- **Settings UI (Notifications sub-page):**
|
||||||
|
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
|
||||||
|
/ 1 h / 1 day / custom), plus the separate all-day default.
|
||||||
|
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
|
||||||
|
or a Notifications subsection), each opening the same OptionCard with a
|
||||||
|
leading **"Use global default"** option.
|
||||||
|
|
||||||
|
### B. Delivery reliability (exact alarms + battery)
|
||||||
|
|
||||||
|
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
|
||||||
|
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
|
||||||
|
it directly undermines the feature's premise, so it rides in here.
|
||||||
|
|
||||||
|
- **Exact alarm — decision first:** trust the provider broadcast, or
|
||||||
|
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
|
||||||
|
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
|
||||||
|
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
|
||||||
|
fallback for API 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.
|
||||||
177
.planning/STATE.md
Normal file
177
.planning/STATE.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Calendula — Current State
|
||||||
|
|
||||||
|
*Last updated: 2026-06-22*
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
||||||
|
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
|
||||||
|
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
|
||||||
|
interop) arc is now done or in flight:
|
||||||
|
- v2.4.0 per-event colors (2026-06-17)
|
||||||
|
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
|
||||||
|
event" launcher shortcut (2026-06-17)
|
||||||
|
- v2.6.0 default reminders (global + per-calendar override, all-day default,
|
||||||
|
battery-exemption row) + system per-app language (2026-06-18)
|
||||||
|
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
|
||||||
|
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
|
||||||
|
(2026-06-18)
|
||||||
|
- v2.7.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**.
|
||||||
134
CHANGELOG.md
134
CHANGELOG.md
@@ -7,136 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
|
||||||
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
|
### 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
|
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||||
were. The **Source code** and **License** links in Settings → About point
|
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
|
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
|
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||||
unaffected.
|
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.
|
|
||||||
|
|
||||||
## [2.16.0] — 2026-07-24
|
## [2.16.0] — 2026-07-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1242,14 +1119,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
||||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
|
||||||
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
|
||||||
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
|
||||||
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
|
|
||||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
|
||||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
|
||||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
|
||||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
|
||||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
|
||||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
|
||||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
|
||||||
|
|||||||
184
CONTRIBUTING.md
184
CONTRIBUTING.md
@@ -1,184 +0,0 @@
|
|||||||
# Contributing to Calendula
|
|
||||||
|
|
||||||
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
|
||||||
Android's `CalendarContract` — no app database, no sync stack, no network access.
|
|
||||||
That constraint shapes most review comments, so
|
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
|
|
||||||
code. This file is the practical how.
|
|
||||||
|
|
||||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
|
|
||||||
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
|
|
||||||
release docs is build infrastructure only; there is nothing to contribute there.
|
|
||||||
Be decent to the people you meet in the tracker.
|
|
||||||
|
|
||||||
## Start with an issue
|
|
||||||
|
|
||||||
| You want to | Do this |
|
|
||||||
|---|---|
|
|
||||||
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
|
||||||
| Fix a bug | Open an issue, then a pull request |
|
|
||||||
| Fix a typo, a comment, or docs | Just open the pull request |
|
|
||||||
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
|
|
||||||
|
|
||||||
Features get an opinion before they get code: whether Calendula should do a
|
|
||||||
thing at all is the one decision a patch can't make. A feature PR that arrives
|
|
||||||
without a discussed issue may be closed unmerged even when the code is good —
|
|
||||||
please don't spend a weekend on one first.
|
|
||||||
|
|
||||||
Bugs are more straightforward, but still start with an issue: it's what carries
|
|
||||||
the milestone and gives the changelog something to link.
|
|
||||||
|
|
||||||
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
|
||||||
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
|
|
||||||
captures the stack trace and prefills the form. The report contains app, Android
|
|
||||||
and device versions plus the trace; no calendar content, no personal data.
|
|
||||||
|
|
||||||
## Which branch to target
|
|
||||||
|
|
||||||
Calendula releases by merging a version bump into `main`, so `main` is a release
|
|
||||||
trigger rather than a staging area. Work is assembled on release branches first.
|
|
||||||
|
|
||||||
Once your issue has a milestone, that milestone names your branch:
|
|
||||||
|
|
||||||
| Milestone | Target branch |
|
|
||||||
|---|---|
|
|
||||||
| `2.18.0` | `release/v2.18.0` |
|
|
||||||
|
|
||||||
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
|
||||||
mention it in the PR — it will be retargeted. Don't pick an older release branch:
|
|
||||||
they're kept after shipping, so the newest one isn't necessarily yours.
|
|
||||||
|
|
||||||
## Translations
|
|
||||||
|
|
||||||
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
|
|
||||||
Translations are owned by a self-hosted Weblate that writes to this repository
|
|
||||||
directly, and a hand-edit is overwritten on the next sync.
|
|
||||||
|
|
||||||
→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
|
||||||
|
|
||||||
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
|
||||||
picks it up and offers it to translators. Partial translations are expected and
|
|
||||||
fine — missing keys are informational. Stale and orphaned keys are not, so run
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 scripts/check_translations.py
|
|
||||||
```
|
|
||||||
|
|
||||||
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
|
||||||
does.
|
|
||||||
|
|
||||||
## Build & test
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
|
||||||
```
|
|
||||||
|
|
||||||
The `floret-kit` submodule is a composite build compiled from source. An
|
|
||||||
existing clone needs `git submodule update --init --recursive`, or nothing
|
|
||||||
resolves.
|
|
||||||
|
|
||||||
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
|
|
||||||
`JAVA_HOME` if your default differs.
|
|
||||||
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
|
||||||
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
|
||||||
go the `local.properties` route the included build needs its own copy at
|
|
||||||
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
|
||||||
easier path.
|
|
||||||
|
|
||||||
The Gradle wrapper is checked in, so no system Gradle is needed.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./gradlew lint test assembleDebug # roughly what CI runs
|
|
||||||
```
|
|
||||||
|
|
||||||
A single test class, or a pattern:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
|
|
||||||
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
|
|
||||||
```
|
|
||||||
|
|
||||||
CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`,
|
|
||||||
`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid
|
|
||||||
metadata or the licence skip the Android build and go green quickly. More detail
|
|
||||||
in [`docs/BUILDING.md`](docs/BUILDING.md).
|
|
||||||
|
|
||||||
## The rules
|
|
||||||
|
|
||||||
These are the ones that turn into review comments.
|
|
||||||
|
|
||||||
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
|
||||||
feature rather than an oversight. Anything that would need one is a product
|
|
||||||
decision before it's a patch — the crash reporter deliberately opens a
|
|
||||||
prefilled web issue instead of posting anything itself.
|
|
||||||
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
|
||||||
events. `CalendarContract` is the single source of truth, which is also why
|
|
||||||
externally synced changes work for free.
|
|
||||||
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
|
||||||
views recompose from fresh provider state. Hand-patching a list after saving
|
|
||||||
appears to work, then quietly diverges from what the provider actually stored.
|
|
||||||
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
|
||||||
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
|
||||||
remain JVM-testable.
|
|
||||||
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
|
||||||
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
|
||||||
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
|
||||||
resort, not a default.
|
|
||||||
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
|
||||||
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
|
||||||
delivery (post-before-mark) follow provider-driven rules that are documented
|
|
||||||
in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from
|
|
||||||
the code alone.
|
|
||||||
7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP
|
|
||||||
metadata block are disabled on purpose so the official F-Droid repo can verify
|
|
||||||
our binary against a from-source rebuild.
|
|
||||||
`scripts/check_reproducible_release.sh` runs on every pull request, including
|
|
||||||
docs-only ones.
|
|
||||||
|
|
||||||
## UI conventions
|
|
||||||
|
|
||||||
Material 3 Expressive throughout, built from the system's own tokens and
|
|
||||||
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
|
||||||
settings rows.
|
|
||||||
|
|
||||||
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
|
||||||
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
|
||||||
extra action passes it through the picker's `actions` slot. The exception is the
|
|
||||||
recurring-scope chooser (*this / this and following / all*), which stays a
|
|
||||||
compact dialog — a two- or three-option decision reads better as a popup than as
|
|
||||||
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
|
||||||
or text-list dialogs aren't used at all.
|
|
||||||
|
|
||||||
Shared UI machinery lives in the `floret-kit` submodule and has
|
|
||||||
[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md);
|
|
||||||
changing it means a pull request against that repository plus a submodule bump
|
|
||||||
here.
|
|
||||||
|
|
||||||
## Commits & pull requests
|
|
||||||
|
|
||||||
Conventional commits, scoped to the area you touched:
|
|
||||||
|
|
||||||
```
|
|
||||||
fix(calendars): keep an event's own calendar when it is switched off
|
|
||||||
feat(month): pull-to-expand the split view (#38)
|
|
||||||
docs(architecture): record what the second review pass changed
|
|
||||||
```
|
|
||||||
|
|
||||||
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
|
||||||
`revert`. Reference the issue in the subject or the body. Keep commits small —
|
|
||||||
small commits revert cleanly, which matters more here than a tidy history.
|
|
||||||
|
|
||||||
If your change is user-visible, add an entry under `## [Unreleased]` in
|
|
||||||
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
|
||||||
what changed *for the person using the app*, and why, not what changed in the
|
|
||||||
code. Link the issue and add its reference at the bottom of the file. It may get
|
|
||||||
reworded when the release is cut, so don't agonise over it.
|
|
||||||
|
|
||||||
Please don't commit planning or design documents. Code, tests, architecture notes
|
|
||||||
and the changelog land; the reasoning belongs in the commit message and the
|
|
||||||
issue.
|
|
||||||
|
|
||||||
## Licence
|
|
||||||
|
|
||||||
Calendula is [MIT](LICENSE). By contributing you agree your changes ship under
|
|
||||||
the same licence.
|
|
||||||
10
Gemfile
10
Gemfile
@@ -1,10 +0,0 @@
|
|||||||
source "https://rubygems.org"
|
|
||||||
|
|
||||||
# fastlane is used ONLY to upload the release bundle to Google Play
|
|
||||||
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
|
|
||||||
# it never runs on a PR — only in release.yaml's `play` job.
|
|
||||||
#
|
|
||||||
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
|
||||||
# committed on purpose: this resolves an uploader's transitive deps, not the
|
|
||||||
# app's, and none of it affects the reproducible release build.
|
|
||||||
gem "fastlane", "2.237.0"
|
|
||||||
101
README.md
101
README.md
@@ -18,18 +18,15 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
<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>
|
<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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<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/01-week.png" width="19%" 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/02-month.png" width="19%" 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="19%" alt="Event detail">
|
||||||
<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-edit.png" width="19%" alt="Event form">
|
||||||
<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="19%" alt="Reminder onboarding">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -71,38 +68,24 @@ database, no sync stack reinvented.
|
|||||||
|
|
||||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||||
expressive motion and shapes, light/dark theme
|
expressive motion and shapes, light/dark theme
|
||||||
- English and German UI plus community translations (Spanish, French, Italian,
|
- German and English UI, per-app language setting — and [open to community
|
||||||
Polish, and more in progress), per-app language setting — and [open to more
|
translations](#-translations)
|
||||||
languages](#-translations)
|
|
||||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||||
never leaves the device
|
never leaves the device
|
||||||
|
|
||||||
## 📦 Install
|
## 📦 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)
|
### F-Droid (recommended)
|
||||||
|
|
||||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||||
just search for **Calendula** in any F-Droid client, or
|
just search for **Calendula** in any F-Droid client, or
|
||||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||||
|
|
||||||
F-Droid rebuilds from source on its own schedule, so a new version usually
|
### Self-hosted repo (latest builds)
|
||||||
shows up there a few days after release.
|
|
||||||
|
|
||||||
### Self-hosted F-Droid repo (fastest updates)
|
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
|
||||||
Every release is built, signed, and published to a self-hosted F-Droid
|
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
||||||
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
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
link below on your phone):
|
link below on your phone):
|
||||||
@@ -117,71 +100,19 @@ and your F-Droid client handles updates from then on:
|
|||||||
|
|
||||||
2. Refresh, search for **Calendula**, install.
|
2. Refresh, search for **Calendula**, install.
|
||||||
|
|
||||||
### Codeberg release / Obtainium
|
Both channels share the same signing key, so you can switch between them
|
||||||
|
without reinstalling. Or build from source — see below.
|
||||||
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
|
## 📚 Documentation
|
||||||
|
|
||||||
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
|
|
||||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
- **[Roadmap](.planning/ROADMAP.md)** — 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
|
## 🌍 Translations
|
||||||
|
|
||||||
Calendula ships in English and German, with community translations in Arabic,
|
Calendula ships in German and English, and you're warmly invited to add your
|
||||||
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
|
language. Translations are managed on a self-hosted **Weblate**:
|
||||||
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/)**
|
**→ [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 +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21701
|
versionCode = 21600
|
||||||
versionName = "2.17.1"
|
versionName = "2.16.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -38,15 +38,3 @@
|
|||||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||||
|
|
||||||
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
|
||||||
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
|
||||||
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
|
||||||
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
|
||||||
# same overrides, no distinguishing members — were horizontally merged into one
|
|
||||||
# class, so both receivers registered under the *same* provider name and
|
|
||||||
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
|
||||||
# placed month widget as the agenda one on the next data change (#89). Keeping
|
|
||||||
# the real names also survives app updates, which would otherwise renumber the
|
|
||||||
# obfuscated name and orphan the stored mapping.
|
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
|
||||||
|
|||||||
@@ -33,14 +33,6 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
<!--
|
|
||||||
A reboot clears every pending alarm, including the one holding the next
|
|
||||||
reminder. Now that the app schedules that alarm itself (#75) rather than
|
|
||||||
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
|
||||||
otherwise reminders simply stop after a restart.
|
|
||||||
-->
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -278,22 +270,17 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
Instances + Reminders instead of waiting for the provider's
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
retarget or never send. This receiver takes our scan alarm plus
|
|
||||||
every outside event that invalidates it — boot and package-replace
|
|
||||||
wipe pending alarms, and a clock or timezone change moves every
|
|
||||||
reminder relative to the one that is armed.
|
|
||||||
Exported: the system broadcasts arrive from outside the app. -->
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.ReminderScheduleReceiver"
|
android:name=".data.reminders.EventReminderReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<data
|
||||||
<action android:name="android.intent.action.TIME_SET" />
|
android:host="com.android.calendar"
|
||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
android:scheme="content" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import android.app.Application
|
|||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -42,37 +39,6 @@ class CalendulaApp : Application() {
|
|||||||
)
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
|
||||||
startReminderDelivery()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
|
||||||
this, ReminderMaintenanceWorker.Deps::class.java,
|
|
||||||
)
|
|
||||||
val scanner = deps.reminderScanner()
|
|
||||||
scanner.startWatchingProvider()
|
|
||||||
scanner.scanInBackground()
|
|
||||||
ReminderMaintenanceScheduler.apply(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
|
||||||
this, CalendarVisibilityReconciler.Deps::class.java,
|
|
||||||
)
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
|
||||||
deps.calendarVisibilityReconciler().run()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,8 +61,10 @@ class CalendulaApp : Application() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||||
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
|
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||||
* ON_RESUME trigger, so no immediate run is needed here.
|
* orphaned work after the feature is turned off and re-schedules after a
|
||||||
|
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||||
|
* refresh, so no immediate run is needed here.
|
||||||
*/
|
*/
|
||||||
private fun reconcileSpecialDates() {
|
private fun reconcileSpecialDates() {
|
||||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
@@ -72,13 +73,19 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||||
* the reminder actually fires on.
|
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||||
|
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||||
|
* after DTSTART) folds to day 0.
|
||||||
*/
|
*/
|
||||||
internal fun fromProviderAllDayMinutes(
|
internal fun fromProviderAllDayMinutes(
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
timeOfDayMinutes: Int,
|
): Int {
|
||||||
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||||
|
.atZone(zone).toLocalDate()
|
||||||
|
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,12 +54,7 @@ import javax.inject.Singleton
|
|||||||
interface CalendarDataSource {
|
interface CalendarDataSource {
|
||||||
fun calendars(): List<CalendarSource>
|
fun calendars(): List<CalendarSource>
|
||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
/**
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
|
||||||
* decode their stored offsets back to whole-day lead times (see
|
|
||||||
* [fromProviderAllDayMinutes]).
|
|
||||||
*/
|
|
||||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master/one-off events whose title, description or location contains
|
* Master/one-off events whose title, description or location contains
|
||||||
@@ -115,28 +110,6 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
/**
|
|
||||||
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
|
|
||||||
* app's one visibility model (#75), which also gates its reminders. One of
|
|
||||||
* the three columns the platform documents as app-writable, and device-local.
|
|
||||||
*/
|
|
||||||
fun setCalendarVisible(id: Long, visible: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether one calendar is switched on at system level, without reading every
|
|
||||||
* row — for the reminder gate, which sees only a calendar id. Null when the
|
|
||||||
* answer can't be had: no row, or no read permission.
|
|
||||||
*/
|
|
||||||
fun isCalendarVisible(id: Long): Boolean?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
|
|
||||||
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
|
|
||||||
* keep their calendar switches app-side instead — see
|
|
||||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
|
|
||||||
*/
|
|
||||||
fun canWriteCalendars(): Boolean
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||||
@@ -400,37 +373,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
|
|
||||||
}
|
|
||||||
val rows = resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
|
||||||
values, null, null,
|
|
||||||
)
|
|
||||||
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isCalendarVisible(id: Long): Boolean? {
|
|
||||||
if (!hasCalendarPermission()) return null
|
|
||||||
return resolver.query(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
|
||||||
arrayOf(CalendarContract.Calendars.VISIBLE),
|
|
||||||
null, null, null,
|
|
||||||
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun canWriteCalendars(): Boolean =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -668,7 +610,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
@@ -677,9 +619,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(
|
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||||
attendees, reminders, allDayReminderTimeMinutes,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,5 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
isManaged = isLocal &&
|
isManaged = isLocal &&
|
||||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||||
// NULL is treated as syncing — the harmless default.
|
|
||||||
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
|
||||||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,16 +38,6 @@ interface CalendarRepository {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
suspend fun deleteCalendar(id: Long)
|
suspend fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
/**
|
|
||||||
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which also gates
|
|
||||||
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
|
|
||||||
* at a time, in order; a failure part-way leaves the earlier writes standing.
|
|
||||||
*
|
|
||||||
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
|
||||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
|
|
||||||
*/
|
|
||||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every event of the writable local calendars, ready to serialise into a
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
|||||||
@@ -16,17 +16,11 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.merge
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -53,120 +47,52 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Bumped on every provider notification, so one tick's calendar read can be
|
|
||||||
* shared by everything that needs it (see [calendarsSnapshot]).
|
|
||||||
*/
|
|
||||||
private val generation = AtomicLong(0L)
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
dataSource.registerChangeListener {
|
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
|
||||||
generation.incrementAndGet()
|
|
||||||
ticks.tryEmit(Unit)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-query signal for everything filtered by visibility: the provider's own
|
|
||||||
* notifications, plus every change to the pending switch-off set.
|
|
||||||
*/
|
|
||||||
private fun visibilityTicks(): Flow<Unit> = merge(
|
|
||||||
ticks.onStart { emit(Unit) },
|
|
||||||
// drop(1): the current value is already covered by the tick above.
|
|
||||||
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
|
||||||
)
|
|
||||||
|
|
||||||
// A switch-off not yet written to the provider is folded into the flag
|
|
||||||
// itself, so every consumer reads one visibility (#75). The reconciler goes
|
|
||||||
// to the data source directly — it needs the provider's own answer.
|
|
||||||
override fun calendars(): Flow<List<CalendarSource>> =
|
override fun calendars(): Flow<List<CalendarSource>> =
|
||||||
visibilityTicks().reQuery {
|
ticks
|
||||||
val calendars = calendarsSnapshot()
|
.onStart { emit(Unit) }
|
||||||
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
|
.reQuery { dataSource.calendars() }
|
||||||
if (pendingDisabled.isEmpty()) calendars
|
|
||||||
else calendars.map {
|
|
||||||
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
// Instances are filtered by the system's VISIBLE flag ∪ the switch-offs
|
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||||
// still waiting to be written to it ∪ the app-side hidden set from the
|
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||||
// filter sheet. [calendars] stays unfiltered so those screens can list and
|
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||||
// re-enable invisible calendars.
|
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||||
|
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||||
|
// list and re-enable hidden/disabled calendars.
|
||||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
combine(
|
combine(
|
||||||
visibilityTicks().reQuery {
|
ticks
|
||||||
// All three reads in one pass, so a list of instances is never
|
.onStart { emit(Unit) }
|
||||||
// filtered against a visibility snapshot from another tick.
|
.reQuery {
|
||||||
QueriedInstances(
|
dataSource.instances(
|
||||||
instances = dataSource.instances(
|
|
||||||
beginMillis = range.start.toEpochMillis(),
|
beginMillis = range.start.toEpochMillis(),
|
||||||
endMillis = range.endInclusive.toEpochMillis(),
|
endMillis = range.endInclusive.toEpochMillis(),
|
||||||
),
|
)
|
||||||
switchedOffCalendarIds = invisibleCalendarIds() +
|
},
|
||||||
prefs.pendingDisabledCalendarIds.first(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { queried, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
val excluded = hidden + queried.switchedOffCalendarIds
|
) { instances, hidden, disabled ->
|
||||||
if (excluded.isEmpty()) queried.instances
|
val excluded = hidden + disabled
|
||||||
else queried.instances.filterNot { it.calendarId in excluded }
|
if (excluded.isEmpty()) instances
|
||||||
|
else instances.filterNot { it.calendarId in excluded }
|
||||||
}
|
}
|
||||||
// Any DataStore edit re-emits the hidden set even when unchanged;
|
// hidden and disabled both derive from one DataStore, so toggling
|
||||||
// collapse those so views don't re-render for them.
|
// either makes both re-emit and combine briefly surfaces the same
|
||||||
|
// list twice — collapse the duplicate so views don't re-render for it.
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
/** One instances query plus the visibility it must be filtered against. */
|
|
||||||
private data class QueriedInstances(
|
|
||||||
val instances: List<EventInstance>,
|
|
||||||
val switchedOffCalendarIds: Set<Long>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Calendars switched off at system level — hidden, and never reminded about. */
|
|
||||||
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
|
||||||
.filterNot { it.isVisibleInSystem }
|
|
||||||
.mapTo(mutableSetOf()) { it.id }
|
|
||||||
|
|
||||||
private val calendarsLock = Mutex()
|
|
||||||
private var cachedGeneration = -1L
|
|
||||||
private var cachedPending: Set<Long>? = null
|
|
||||||
private var cachedCalendars: List<CalendarSource> = emptyList()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The calendar list for the current tick, queried once and shared, so every
|
|
||||||
* open view doesn't pay for its own `Calendars` query and all of them see
|
|
||||||
* one snapshot.
|
|
||||||
*
|
|
||||||
* 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()
|
|
||||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
|
||||||
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
|
|
||||||
cachedCalendars = dataSource.calendars()
|
|
||||||
cachedGeneration = current
|
|
||||||
cachedPending = pending
|
|
||||||
}
|
|
||||||
cachedCalendars
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
?: throw NoSuchEventException(eventId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val excluded = prefs.hiddenCalendarIds.first() +
|
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||||
prefs.pendingDisabledCalendarIds.first() +
|
|
||||||
invisibleCalendarIds()
|
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
@@ -192,22 +118,6 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
|
||||||
withContext(io) {
|
|
||||||
if (dataSource.canWriteCalendars()) {
|
|
||||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
|
||||||
// Nothing of ours is left waiting for the provider once the
|
|
||||||
// write lands (and switching one back on retires its entry).
|
|
||||||
prefs.removePendingDisabledCalendarIds(ids)
|
|
||||||
} else if (visible) {
|
|
||||||
prefs.removePendingDisabledCalendarIds(ids)
|
|
||||||
} else {
|
|
||||||
// Read-only permission: the switch still works, app-side, and
|
|
||||||
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
|
|
||||||
prefs.addPendingDisabledCalendarIds(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||||
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.EntryPoint
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
|
||||||
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
|
|
||||||
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
|
|
||||||
* visibility model, and the standing drain for switch-offs made without
|
|
||||||
* `WRITE_CALENDAR`.
|
|
||||||
*
|
|
||||||
* Runs on every launch and on every calendar-permission grant; a no-op once the
|
|
||||||
* pending set is empty and the notice is settled. Only ever hides (see
|
|
||||||
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
|
|
||||||
* system-hidden calendar arms the one-time explanatory notice.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class CalendarVisibilityReconciler @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val dataSource: CalendarDataSource,
|
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun run() = withContext(io) {
|
|
||||||
// The DataStore reads sit inside the guard too: this runs in a bare
|
|
||||||
// application-scope coroutine, so an IOException from a damaged
|
|
||||||
// preferences file would take the process down on every launch.
|
|
||||||
try {
|
|
||||||
// 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
|
|
||||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
|
||||||
val calendars = dataSource.calendars()
|
|
||||||
// Empty means "couldn't read" (null cursor), not "no calendars".
|
|
||||||
// Both decisions below are one-way, so leave them to the next run.
|
|
||||||
if (calendars.isEmpty()) return@withContext
|
|
||||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
|
||||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
val plan = calendarVisibilityPlan(calendars, pending)
|
|
||||||
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]).
|
|
||||||
for (id in plan.hide) {
|
|
||||||
dataSource.setCalendarVisible(id, false)
|
|
||||||
prefs.removePendingDisabledCalendarIds(setOf(id))
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settle the one-time notice: [pending] arms it, false retires it unshown.
|
|
||||||
* Answered once and stored either way, so it can't resurface later.
|
|
||||||
*/
|
|
||||||
private suspend fun settleNoticeOnce(pending: Boolean) {
|
|
||||||
if (prefs.visibilityNoticePending.first() != null) return
|
|
||||||
prefs.setVisibilityNoticePending(pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this install has ever run an earlier version. The notice explains
|
|
||||||
* a behaviour change, so a first install has nothing to announce.
|
|
||||||
*/
|
|
||||||
private fun isUpgradeInstall(): Boolean = try {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
||||||
info.lastUpdateTime > info.firstInstallTime
|
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
|
||||||
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasPermission(permission: String): Boolean =
|
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
|
||||||
@EntryPoint
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface Deps {
|
|
||||||
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "CalendarVisibility"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ private const val TAG = "EventDetailMapper"
|
|||||||
internal fun ColumnReader.toEventDetailCore(
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
@@ -90,13 +89,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val displayReminders = if (isAllDay) {
|
val displayReminders = if (isAllDay) {
|
||||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
reminders.map {
|
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||||
it.copy(
|
|
||||||
minutes = fromProviderAllDayMinutes(
|
|
||||||
it.minutes, startDate, zone, allDayReminderTimeMinutes,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
reminders
|
reminders
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ internal object CalendarProjection {
|
|||||||
// uses to recognise its own managed calendars, independent of any
|
// uses to recognise its own managed calendars, independent of any
|
||||||
// stored preference id (which a backup restore / data wipe can lose).
|
// stored preference id (which a backup restore / data wipe can lose).
|
||||||
MANAGED_MARKER_COLUMN,
|
MANAGED_MARKER_COLUMN,
|
||||||
CalendarContract.Calendars.SYNC_EVENTS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
@@ -37,7 +36,6 @@ internal object CalendarProjection {
|
|||||||
const val IDX_ACCESS_LEVEL = 6
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
const val IDX_DESCRIPTION = 7
|
const val IDX_DESCRIPTION = 7
|
||||||
const val IDX_MANAGED_MARKER = 8
|
const val IDX_MANAGED_MARKER = 8
|
||||||
const val IDX_SYNC_EVENTS = 9
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindReminderInstanceSource(
|
abstract fun bindReminderAlertStore(
|
||||||
impl: ProviderReminderInstanceSource,
|
impl: AndroidReminderAlertStore,
|
||||||
): ReminderInstanceSource
|
): ReminderAlertStore
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.prefs
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.MutablePreferences
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
* App-side preference for "calendars the user has hidden in this app",
|
||||||
* sheet — a purely in-app declutter that deliberately does *not* suppress
|
* separate from the system's per-calendar VISIBLE flag.
|
||||||
* reminders. Switching a calendar off entirely is the system's
|
|
||||||
* `Calendars.VISIBLE` flag, written straight to the provider (#75);
|
|
||||||
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
|
|
||||||
* allowed to write yet.
|
|
||||||
*
|
*
|
||||||
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||||
* silently dropped (defensive — see CalendarPrefsTest).
|
* silently dropped (defensive — see CalendarPrefsTest).
|
||||||
@@ -29,56 +22,46 @@ class CalendarPrefs @Inject constructor(
|
|||||||
private val store: DataStore<Preferences>,
|
private val store: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
|
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||||
// unrelated write would otherwise re-emit an identical set.
|
prefs[HIDDEN_IDS_KEY].orEmpty()
|
||||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
.split(',')
|
||||||
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
.mapNotNull { it.trim().toLongOrNull() }
|
||||||
.distinctUntilChanged()
|
.toSet()
|
||||||
|
|
||||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
|
||||||
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||||
* 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 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() }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
|
|
||||||
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
|
|
||||||
editPendingDisabled { it + ids }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop [ids] from the pending set, one at a time as the reconciler flushes
|
|
||||||
* them, so a run that fails part-way never re-applies what already landed.
|
|
||||||
*/
|
|
||||||
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
|
||||||
editPendingDisabled { it - ids.toSet() }
|
|
||||||
|
|
||||||
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
if (ids.isEmpty()) {
|
||||||
|
prefs.remove(HIDDEN_IDS_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the one-time "visibility follows this device" notice is still
|
* App-side preference for "calendars the user has disabled in this app" — a
|
||||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||||
* permission), false once it has been shown or was never needed.
|
* every surface (drawer filter, event-form picker, import picker) and its
|
||||||
|
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||||
|
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||||
|
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||||
*/
|
*/
|
||||||
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||||
prefs[VISIBILITY_NOTICE_KEY]
|
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
store.edit { prefs ->
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
prefs.remove(DISABLED_IDS_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,16 +79,6 @@ class CalendarPrefs @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||||
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
|
|
||||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String?.parseIds(): Set<Long> = orEmpty()
|
|
||||||
.split(',')
|
|
||||||
.mapNotNull { it.trim().toLongOrNull() }
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
|
|
||||||
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.prefs
|
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far reminder delivery has got — the one number replacing the provider's
|
|
||||||
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
|
|
||||||
* watermark and up to now, then moves it to now, so a scan running twice cannot
|
|
||||||
* post twice while a late one still catches up.
|
|
||||||
*
|
|
||||||
* 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(
|
|
||||||
private val store: DataStore<Preferences>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** The watermark, or `null` before the first scan has ever run. */
|
|
||||||
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
|
||||||
|
|
||||||
suspend fun setLastScanMillis(millis: Long) {
|
|
||||||
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,6 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -307,23 +306,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The size step the agenda widget draws its text at (#51). Defaults to
|
|
||||||
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
|
|
||||||
* widget is unchanged until its owner turns the size up.
|
|
||||||
*
|
|
||||||
* This replaced deriving a size tier from the widget's measured size, which
|
|
||||||
* the launcher does not report reliably. The month widget takes no size
|
|
||||||
* setting — it divides the width it is given by seven (#103).
|
|
||||||
*/
|
|
||||||
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
|
|
||||||
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setWidgetSize(size: WidgetSize) {
|
|
||||||
store.edit { it[WIDGET_SIZE_KEY] = size.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||||
* the session range switcher (v2.11). Default ON.
|
* the session range switcher (v2.11). Default ON.
|
||||||
@@ -799,7 +781,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
|
|
||||||
internal val AGENDA_SHOW_TODAY_KEY =
|
internal val AGENDA_SHOW_TODAY_KEY =
|
||||||
booleanPreferencesKey("agenda_show_today")
|
booleanPreferencesKey("agenda_show_today")
|
||||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||||
|
* reminder must be suppressed (mirroring the event filtering in
|
||||||
|
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||||
|
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||||
|
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||||
|
* gate is built from: [postableAlerts] here and the choke point in
|
||||||
|
* [ReminderNotifier.post] both use it.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||||
|
calendarId != 0L && calendarId in disabledCalendarIds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The due alerts that should actually surface as notifications: everything
|
||||||
|
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||||
|
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||||
|
* provider.
|
||||||
|
*/
|
||||||
|
internal fun postableAlerts(
|
||||||
|
due: List<ReminderAlert>,
|
||||||
|
disabledCalendarIds: Set<Long>,
|
||||||
|
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
|
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||||
|
*
|
||||||
|
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||||
|
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||||
|
* them, and mark them fired. Posting happens before marking — a crash in
|
||||||
|
* between re-posts silently (same tag) rather than losing the reminder.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class EventReminderReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||||
|
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
|
val readGranted = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!readGranted || !notifier.canPost()) return
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val due = alertStore.dueAlerts(now)
|
||||||
|
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||||
|
val postable = postableAlerts(due, disabled)
|
||||||
|
// Suppress reminders for disabled calendars, but still mark
|
||||||
|
// every due alert fired so the provider stops re-broadcasting
|
||||||
|
// the suppressed ones. Stash those suppressed alerts so
|
||||||
|
// re-enabling their calendar can recover them (they would
|
||||||
|
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||||
|
postable.forEach { notifier.post(it) }
|
||||||
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
suppressedStore.stash(due - postable.toSet(), now)
|
||||||
|
suppressedStore.purgeExpired(now)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.net.toUri
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -19,14 +18,12 @@ import javax.inject.Inject
|
|||||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
* alarm), so the receiver is not exported.
|
* alarm), so the receiver is not exported.
|
||||||
*
|
*
|
||||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||||
* past this reminder, so nothing re-posts it.
|
* already fired, so nothing re-posts it.
|
||||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
* it after the user's snooze delay.
|
* it after the user's snooze delay.
|
||||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||||
* or dismiss it again — unless the calendar was switched off during the
|
* or dismiss it again.
|
||||||
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
|
||||||
* trigger, outside the ordinary scan.
|
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ReminderActionReceiver : BroadcastReceiver() {
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
@@ -76,14 +73,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||||
|
|
||||||
/**
|
private const val EXTRA_ALERT_ID = "alert_id"
|
||||||
* Not handled here — the notification body opens the detail screen
|
|
||||||
* directly. It only claims a slot in [requestCode] so that intent stays
|
|
||||||
* distinct from the three this receiver does handle.
|
|
||||||
*/
|
|
||||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
|
||||||
|
|
||||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
|
||||||
private const val EXTRA_EVENT_ID = "event_id"
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
private const val EXTRA_BEGIN = "begin"
|
private const val EXTRA_BEGIN = "begin"
|
||||||
@@ -92,16 +82,11 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_LOCATION = "location"
|
private const val EXTRA_LOCATION = "location"
|
||||||
private const val EXTRA_ALL_DAY = "all_day"
|
private const val EXTRA_ALL_DAY = "all_day"
|
||||||
|
|
||||||
/**
|
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||||
* An explicit intent to this receiver carrying [alert] as extras. The
|
|
||||||
* data URI duplicates no information but is what keeps two reminders'
|
|
||||||
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
|
||||||
*/
|
|
||||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
this.action = action
|
this.action = action
|
||||||
data = "calendula://reminder/${alert.key}".toUri()
|
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
|
||||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
@@ -112,25 +97,23 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stable request code per (alert, action), so one notification's
|
* A stable request code per (alert, action) so the three PendingIntents
|
||||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
* of one notification stay distinct and don't clobber each other.
|
||||||
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
|
||||||
*/
|
*/
|
||||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
val actionOffset = when (action) {
|
val actionOffset = when (action) {
|
||||||
ACTION_SNOOZE -> 1
|
ACTION_SNOOZE -> 1
|
||||||
ACTION_DISMISS -> 2
|
ACTION_DISMISS -> 2
|
||||||
ACTION_SHOW -> 3
|
ACTION_SHOW -> 3
|
||||||
ACTION_OPEN -> 4
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
return (alert.key.toInt() shl 3) + actionOffset
|
return alert.alertId.toInt() * 8 + actionOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
|
||||||
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
|
||||||
* calendar app), user-revocable on 31–32.
|
|
||||||
*/
|
|
||||||
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
|
||||||
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
|
||||||
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
|
|
||||||
* withholds the capability (API 31–32 with the permission revoked).
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ReminderAlarmScheduler @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun scheduleScan(triggerAtMillis: Long) {
|
|
||||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
|
||||||
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
if (alarmManager.canScheduleExactCompat()) {
|
|
||||||
alarmManager.setExactAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
alarmManager.setAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
|
||||||
fun cancelScan() {
|
|
||||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
|
||||||
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
SCAN_REQUEST_CODE,
|
|
||||||
Intent(context, ReminderScheduleReceiver::class.java)
|
|
||||||
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
|
||||||
flags or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
|
||||||
const val SCAN_REQUEST_CODE = 0x5CA1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One reminder as the notification layer needs it: what to show, and the stable
|
|
||||||
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
|
|
||||||
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
|
|
||||||
* `CalendarAlerts` row to take an id from (#75).
|
|
||||||
*/
|
|
||||||
data class ReminderAlert(
|
|
||||||
val key: Long,
|
|
||||||
val eventId: Long,
|
|
||||||
val calendarId: Long,
|
|
||||||
val beginMillis: Long,
|
|
||||||
val endMillis: Long,
|
|
||||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
|
||||||
val title: String,
|
|
||||||
val location: String?,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
|
||||||
key = key,
|
|
||||||
eventId = instance.eventId,
|
|
||||||
calendarId = instance.calendarId,
|
|
||||||
beginMillis = instance.beginMillis,
|
|
||||||
endMillis = instance.endMillis,
|
|
||||||
title = instance.title,
|
|
||||||
location = instance.location,
|
|
||||||
isAllDay = instance.isAllDay,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||||
|
* Stays in the data layer: alerts feed the notification path only and never
|
||||||
|
* reach a screen, so there is no domain model for them.
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val alertId: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||||
|
* without a ContentResolver. The provider creates these rows itself — only
|
||||||
|
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||||
|
* email reminders never show up here.
|
||||||
|
*/
|
||||||
|
interface ReminderAlertStore {
|
||||||
|
|
||||||
|
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||||
|
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||||
|
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||||
|
* which the user may have declined — then re-broadcasts silently replace
|
||||||
|
* the already-posted notifications instead (same tag, alert-once).
|
||||||
|
*/
|
||||||
|
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidReminderAlertStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderAlertStore {
|
||||||
|
|
||||||
|
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||||
|
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||||
|
nowMillis.toString(),
|
||||||
|
),
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = c.getLong(0),
|
||||||
|
eventId = c.getLong(1),
|
||||||
|
calendarId = c.getLong(2),
|
||||||
|
beginMillis = c.getLong(3),
|
||||||
|
endMillis = c.getLong(4),
|
||||||
|
title = c.getString(5).orEmpty(),
|
||||||
|
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(7) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||||
|
if (alertIds.isEmpty()) return
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||||
|
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||||
|
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.contentResolver.update(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
values,
|
||||||
|
CalendarContract.CalendarAlerts._ID +
|
||||||
|
" IN (" + alertIds.joinToString(",") + ")",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderAlertStore"
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts._ID,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
|
CalendarContract.CalendarAlerts.END,
|
||||||
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||||
|
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The read side of in-house reminder delivery: occurrences and their reminder
|
|
||||||
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
|
|
||||||
* which cannot be assumed to be written (#75).
|
|
||||||
*
|
|
||||||
* An interface so [ReminderScanner] can be exercised on the JVM.
|
|
||||||
*/
|
|
||||||
interface ReminderInstanceSource {
|
|
||||||
|
|
||||||
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
|
||||||
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
|
||||||
|
|
||||||
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
|
||||||
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
|
||||||
* window can be stretched to cover it and a long-lead reminder is planned
|
|
||||||
* before it comes due rather than firing late.
|
|
||||||
*/
|
|
||||||
fun longestReminderMinutes(): Int
|
|
||||||
}
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ProviderReminderInstanceSource @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
) : ReminderInstanceSource {
|
|
||||||
|
|
||||||
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
|
||||||
ContentUris.appendId(this, fromMillis)
|
|
||||||
ContentUris.appendId(this, toMillis)
|
|
||||||
}.build()
|
|
||||||
// `visible` is the flag the app's one visibility model writes (#75).
|
|
||||||
// The status clause mirrors CalendarDataSource.instances: NULL means
|
|
||||||
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
|
||||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
|
||||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
|
||||||
return context.contentResolver.query(
|
|
||||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
|
||||||
)?.use { c ->
|
|
||||||
buildList {
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
add(
|
|
||||||
ReminderEventInstance(
|
|
||||||
eventId = c.getLong(0),
|
|
||||||
calendarId = c.getLong(1),
|
|
||||||
beginMillis = c.getLong(2),
|
|
||||||
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
|
||||||
title = c.getString(4).orEmpty(),
|
|
||||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
|
||||||
isAllDay = c.getInt(6) == 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
|
||||||
if (eventIds.isEmpty()) return emptyMap()
|
|
||||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
|
||||||
// Batched: the ids go into the selection literally, and an unbounded
|
|
||||||
// `IN (...)` would grow the SQL past what SQLite takes.
|
|
||||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
|
||||||
context.contentResolver.query(
|
|
||||||
CalendarContract.Reminders.CONTENT_URI,
|
|
||||||
REMINDER_PROJECTION,
|
|
||||||
"${CalendarContract.Reminders.METHOD} = " +
|
|
||||||
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
|
||||||
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
)?.use { c ->
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
|
||||||
CalendarContract.Reminders.CONTENT_URI,
|
|
||||||
arrayOf(CalendarContract.Reminders.MINUTES),
|
|
||||||
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
|
||||||
null,
|
|
||||||
// One row is enough: the provider passes the sort order to SQLite.
|
|
||||||
"${CalendarContract.Reminders.MINUTES} DESC",
|
|
||||||
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val EVENT_ID_BATCH = 50
|
|
||||||
|
|
||||||
val OCCURRENCE_PROJECTION = arrayOf(
|
|
||||||
CalendarContract.Instances.EVENT_ID,
|
|
||||||
CalendarContract.Instances.CALENDAR_ID,
|
|
||||||
CalendarContract.Instances.BEGIN,
|
|
||||||
CalendarContract.Instances.END,
|
|
||||||
CalendarContract.Instances.TITLE,
|
|
||||||
CalendarContract.Instances.EVENT_LOCATION,
|
|
||||||
CalendarContract.Instances.ALL_DAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
val REMINDER_PROJECTION = arrayOf(
|
|
||||||
CalendarContract.Reminders.EVENT_ID,
|
|
||||||
CalendarContract.Reminders.MINUTES,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import dagger.hilt.EntryPoint
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
|
||||||
* survived. Finds nothing to do in the steady state; it exists for the device
|
|
||||||
* that quietly drops the alarm without a reboot to announce it (#75).
|
|
||||||
*/
|
|
||||||
object ReminderMaintenanceScheduler {
|
|
||||||
|
|
||||||
private const val WORK_NAME = "reminder-scan-maintenance"
|
|
||||||
|
|
||||||
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
|
||||||
fun apply(context: Context) {
|
|
||||||
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
|
||||||
// The launch scan covers now; let the first periodic run wait.
|
|
||||||
.setInitialDelay(1, TimeUnit.DAYS)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance(context)
|
|
||||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReminderMaintenanceWorker(
|
|
||||||
appContext: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(appContext, params) {
|
|
||||||
|
|
||||||
@EntryPoint
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface Deps {
|
|
||||||
fun reminderScanner(): ReminderScanner
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun doWork(): Result = try {
|
|
||||||
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
|
||||||
.reminderScanner()
|
|
||||||
.scan()
|
|
||||||
Result.success()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// The scan swallows its own failures, so anything reaching here is the
|
|
||||||
// entry point itself — a retry will not mend it.
|
|
||||||
Log.w(TAG, "Reminder maintenance scan failed", e)
|
|
||||||
Result.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "ReminderMaintenance"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
@@ -29,20 +28,17 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
* Posts one notification per due reminder alert on a dedicated channel.
|
||||||
* the event's detail screen.
|
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||||
*
|
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
* duplicating it.
|
||||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
|
||||||
* instead of stacking a second one.
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val calendarPrefs: CalendarPrefs,
|
private val calendarPrefs: CalendarPrefs,
|
||||||
private val calendarDataSource: CalendarDataSource,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -53,23 +49,12 @@ class ReminderNotifier @Inject constructor(
|
|||||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun post(alert: ReminderAlert) {
|
||||||
* The single choke point for "this calendar is switched off", covering the
|
// The single choke point for the disabled-calendar gate: it covers both
|
||||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||||
* before the switch-off, and a read-only install whose switch lives in
|
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||||
* [CalendarPrefs].
|
// longer notifies — without either receiver duplicating the check.
|
||||||
*/
|
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
|
||||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
|
||||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
|
||||||
* notification was put up, which the snooze re-show path uses to tell a
|
|
||||||
* silenced reminder from a delivered one.
|
|
||||||
*/
|
|
||||||
suspend fun post(alert: ReminderAlert): Boolean {
|
|
||||||
if (isSilenced(alert.calendarId)) return false
|
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
@@ -116,18 +101,16 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
}
|
}
|
||||||
// Handled either way — a retry hits the same revoked permission.
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
fun cancel(alert: ReminderAlert) {
|
fun cancel(alert: ReminderAlert) {
|
||||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
@@ -140,10 +123,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
/* requestCode = */ ReminderActionReceiver.requestCode(
|
|
||||||
alert, ReminderActionReceiver.ACTION_OPEN,
|
|
||||||
),
|
|
||||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.time.ZoneId
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One pass of in-house reminder delivery: read what is planned, post what has
|
|
||||||
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
|
|
||||||
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
|
|
||||||
* is owed, not the trigger.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ReminderScanner @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val source: ReminderInstanceSource,
|
|
||||||
private val calendarDataSource: CalendarDataSource,
|
|
||||||
private val notifier: ReminderNotifier,
|
|
||||||
private val alarms: ReminderAlarmScheduler,
|
|
||||||
private val state: ReminderStatePrefs,
|
|
||||||
private val settingsPrefs: SettingsPrefs,
|
|
||||||
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
|
||||||
) {
|
|
||||||
|
|
||||||
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
|
||||||
// two passes can't both read the same watermark and post the same reminder.
|
|
||||||
private val scanLock = Mutex()
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + io)
|
|
||||||
private val providerChanges = MutableSharedFlow<Unit>(
|
|
||||||
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
|
||||||
)
|
|
||||||
private var watching = false
|
|
||||||
|
|
||||||
suspend fun scan() = withContext(io) {
|
|
||||||
scanLock.withLock {
|
|
||||||
try {
|
|
||||||
runScan()
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runScan() {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (!hasReadCalendar()) return
|
|
||||||
|
|
||||||
if (!settingsPrefs.remindersEnabled.first()) {
|
|
||||||
// Reminders off: drop the wake-up, but keep the watermark moving
|
|
||||||
// so switching them back on doesn't replay the backlog.
|
|
||||||
alarms.cancelScan()
|
|
||||||
state.setLastScanMillis(now)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
|
||||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
|
||||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
|
||||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
|
||||||
val planned = planReminders(
|
|
||||||
instances = occurrences,
|
|
||||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
|
||||||
zone = ZoneId.systemDefault(),
|
|
||||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val schedule = scheduleReminders(
|
|
||||||
planned = planned,
|
|
||||||
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
|
||||||
nowMillis = now,
|
|
||||||
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (notifier.canPost()) {
|
|
||||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
|
||||||
}
|
|
||||||
// Advance even when nothing could be posted, so muting notifications
|
|
||||||
// doesn't build a backlog.
|
|
||||||
state.setLastScanMillis(now)
|
|
||||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
|
||||||
context, Manifest.permission.READ_CALENDAR,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
|
||||||
* alarm at once. Debounced, since a single save lands as several
|
|
||||||
* notifications. Process-lifetime only; other triggers cover the rest.
|
|
||||||
*/
|
|
||||||
fun startWatchingProvider() {
|
|
||||||
if (watching) return
|
|
||||||
watching = true
|
|
||||||
providerChanges
|
|
||||||
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
|
||||||
.onEach { scan() }
|
|
||||||
.launchIn(scope)
|
|
||||||
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
|
||||||
fun scanInBackground() {
|
|
||||||
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "ReminderScanner"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far ahead occurrences are read. Stretched further by the longest
|
|
||||||
* reminder offset in the table, so this is only the floor.
|
|
||||||
*/
|
|
||||||
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
/** How far back to look for occurrences that may still owe a reminder. */
|
|
||||||
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Never wait longer than a day for the next pass: it rolls the lookahead
|
|
||||||
* window forward and re-arms an alarm the system may have dropped.
|
|
||||||
*/
|
|
||||||
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
|
||||||
|
|
||||||
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
|
|
||||||
* alarm, boot and package-replaced (both wipe pending alarms), and time or
|
|
||||||
* timezone changes (both move reminders relative to the armed alarm). All do the
|
|
||||||
* same thing, since [ReminderScanner.scan] is idempotent.
|
|
||||||
*
|
|
||||||
* Exported for the system broadcasts; an early scan triggered by another app is
|
|
||||||
* harmless.
|
|
||||||
*/
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class ReminderScheduleReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
@Inject lateinit var scanner: ReminderScanner
|
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
// Checked despite every action doing the same thing: the receiver is
|
|
||||||
// exported and the broadcasts it takes are protected, so any other
|
|
||||||
// action did not come from where it claims to.
|
|
||||||
if (intent.action !in HANDLED_ACTIONS) return
|
|
||||||
val pendingResult = goAsync()
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
scanner.scan()
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
|
||||||
|
|
||||||
private val HANDLED_ACTIONS = setOf(
|
|
||||||
ACTION_SCAN,
|
|
||||||
Intent.ACTION_BOOT_COMPLETED,
|
|
||||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
|
||||||
Intent.ACTION_TIME_CHANGED,
|
|
||||||
Intent.ACTION_TIMEZONE_CHANGED,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,14 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
|
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||||
* exact-alarm capability (API 31–32 with the permission revoked).
|
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||||
|
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||||
|
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||||
|
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||||
|
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderSnoozeScheduler @Inject constructor(
|
class ReminderSnoozeScheduler @Inject constructor(
|
||||||
@@ -36,8 +37,8 @@ class ReminderSnoozeScheduler @Inject constructor(
|
|||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||||
// available without nagging for SCHEDULE_EXACT_ALARM.
|
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||||
alarmManager.setAndAllowWhileIdle(
|
alarmManager.setAndAllowWhileIdle(
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Still relevant while the event has not ended: a reminder for an event that is
|
||||||
|
* already over is pointless to re-surface. Falls back to the begin time when the
|
||||||
|
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||||
|
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local stash of reminder alerts that fired while their calendar was disabled
|
||||||
|
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
||||||
|
* (so the provider stops re-broadcasting the suppressed ones), which would
|
||||||
|
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
||||||
|
* the user re-enables the calendar before the event is over.
|
||||||
|
*
|
||||||
|
* Persisted in the shared preferences DataStore as a set of self-describing
|
||||||
|
* strings (one per alert); the stash never reaches a screen, so there is no
|
||||||
|
* domain model. Entries whose event has already ended are dropped on the next
|
||||||
|
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SuppressedReminderStore @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
||||||
|
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
||||||
|
if (alerts.isEmpty()) return
|
||||||
|
store.edit { prefs ->
|
||||||
|
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
||||||
|
alerts.forEach { byId[it.alertId] = it }
|
||||||
|
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove and return the still-relevant stashed alerts belonging to any of
|
||||||
|
* [calendarIds]; drops expired entries for every calendar in passing.
|
||||||
|
*/
|
||||||
|
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
||||||
|
val recovered = mutableListOf<ReminderAlert>()
|
||||||
|
store.edit { prefs ->
|
||||||
|
val kept = decodeAll(prefs).filter { alert ->
|
||||||
|
when {
|
||||||
|
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
||||||
|
alert.calendarId in calendarIds -> { recovered += alert; false }
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.putStash(kept)
|
||||||
|
}
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
||||||
|
suspend fun purgeExpired(nowMillis: Long) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
||||||
|
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
||||||
|
|
||||||
|
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
||||||
|
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
||||||
|
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One stash entry as a delimited string. The '|' separator is safe because every
|
||||||
|
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
||||||
|
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
||||||
|
private const val FIELD_SEP = "|"
|
||||||
|
private const val NULL_LOCATION = "-"
|
||||||
|
|
||||||
|
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
||||||
|
alert.alertId.toString(),
|
||||||
|
alert.eventId.toString(),
|
||||||
|
alert.calendarId.toString(),
|
||||||
|
alert.beginMillis.toString(),
|
||||||
|
alert.endMillis.toString(),
|
||||||
|
if (alert.isAllDay) "1" else "0",
|
||||||
|
alert.title.toBase64(),
|
||||||
|
alert.location?.toBase64() ?: NULL_LOCATION,
|
||||||
|
).joinToString(FIELD_SEP)
|
||||||
|
|
||||||
|
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
||||||
|
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
||||||
|
val parts = raw.split(FIELD_SEP)
|
||||||
|
if (parts.size != 8) return null
|
||||||
|
return try {
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = parts[0].toLong(),
|
||||||
|
eventId = parts[1].toLong(),
|
||||||
|
calendarId = parts[2].toLong(),
|
||||||
|
beginMillis = parts[3].toLong(),
|
||||||
|
endMillis = parts[4].toLong(),
|
||||||
|
title = parts[6].fromBase64(),
|
||||||
|
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
||||||
|
isAllDay = parts[5] == "1",
|
||||||
|
)
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
null
|
||||||
|
} catch (e: IllegalArgumentException) { // bad Base64
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toBase64(): String =
|
||||||
|
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
private fun String.fromBase64(): String =
|
||||||
|
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ways a calendar can behave unlike a plain, writable one — each a reason it
|
|
||||||
* is missing from the event and import pickers (#76).
|
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
MANAGED,
|
|
||||||
|
|
||||||
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
|
||||||
READ_ONLY,
|
|
||||||
|
|
||||||
/** The account holds the events, but this device isn't syncing them down. */
|
|
||||||
NOT_SYNCED,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, with no events on the device.
|
|
||||||
*/
|
|
||||||
val CalendarSource.hasVisibilitySwitch: Boolean
|
|
||||||
get() = !isNotSynced
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this calendar can be offered as a target for a new or imported event.
|
|
||||||
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
|
||||||
* names on a manager row are exactly the states that keep a calendar out of them
|
|
||||||
* (#76). An event already living in an excluded calendar keeps it; the editor
|
|
||||||
* adds that calendar back to its picker.
|
|
||||||
*/
|
|
||||||
val CalendarSource.isEventTarget: Boolean
|
|
||||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
|
||||||
|
|
||||||
/** Every state worth naming on this calendar's row, in reading order. */
|
|
||||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
|
||||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
|
||||||
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
|
||||||
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calendar-manager order within one group: the ones you can actually act on
|
|
||||||
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
|
||||||
* display-name ordering survives.
|
|
||||||
*/
|
|
||||||
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
|
||||||
sortedBy { it.isNotSynced }
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
|
||||||
* set into the provider, plus the ids that need no write at all.
|
|
||||||
*/
|
|
||||||
data class CalendarVisibilityPlan(
|
|
||||||
val hide: Set<Long> = emptySet(),
|
|
||||||
val settled: Set<Long> = emptySet(),
|
|
||||||
) {
|
|
||||||
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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 needing no write — already
|
|
||||||
* hidden, or gone from the device.
|
|
||||||
*/
|
|
||||||
fun calendarVisibilityPlan(
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
pendingDisabledIds: Set<Long>,
|
|
||||||
): CalendarVisibilityPlan {
|
|
||||||
val byId = calendars.associateBy { it.id }
|
|
||||||
val hide = mutableSetOf<Long>()
|
|
||||||
val settled = mutableSetOf<Long>()
|
|
||||||
for (id in pendingDisabledIds) {
|
|
||||||
val calendar = byId[id]
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether any calendar is switched off at system level without Calendula having
|
|
||||||
* asked for it — the condition the one-time notice explains.
|
|
||||||
*/
|
|
||||||
fun hasSystemHiddenCalendars(
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
pendingDisabledIds: Set<Long>,
|
|
||||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
data class CalendarSource(
|
data class CalendarSource(
|
||||||
@@ -12,11 +8,6 @@ data class CalendarSource(
|
|||||||
val accountName: String,
|
val accountName: String,
|
||||||
val accountType: String,
|
val accountType: String,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
/**
|
|
||||||
* The system's `Calendars.VISIBLE` flag — the single visibility model,
|
|
||||||
* deciding both what Calendula shows and whether this calendar plans
|
|
||||||
* reminders (#75). The drawer's filter sheet is a separate in-app declutter.
|
|
||||||
*/
|
|
||||||
val isVisibleInSystem: Boolean,
|
val isVisibleInSystem: Boolean,
|
||||||
/**
|
/**
|
||||||
* Whether events in this calendar can be created/edited/deleted
|
* Whether events in this calendar can be created/edited/deleted
|
||||||
@@ -43,13 +34,6 @@ data class CalendarSource(
|
|||||||
* even after a backup restore clears the app's stored ids.
|
* even after a backup restore clears the app's stored ids.
|
||||||
*/
|
*/
|
||||||
val isManaged: Boolean = false,
|
val isManaged: Boolean = false,
|
||||||
/**
|
|
||||||
* Whether the provider keeps this calendar's events on the device
|
|
||||||
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
|
|
||||||
* nothing about device-local calendars, which can hold events with it off.
|
|
||||||
* Read for the "not synced" row label (#76).
|
|
||||||
*/
|
|
||||||
val syncsEvents: Boolean = true,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -72,31 +56,6 @@ data class EventInstance(
|
|||||||
*/
|
*/
|
||||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||||
|
|
||||||
/**
|
|
||||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
|
||||||
* events, UTC for all-day ones, whose midnights would otherwise shift day
|
|
||||||
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
|
|
||||||
*/
|
|
||||||
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
|
||||||
if (isAllDay) TimeZone.UTC else zone
|
|
||||||
|
|
||||||
/** The first calendar day this event occupies. */
|
|
||||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
|
||||||
start.toLocalDateTime(dateZone(zone)).date
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last calendar day this event occupies. An event ending exactly at midnight
|
|
||||||
* does not reach into that day, so resolve just before [EventInstance.end].
|
|
||||||
*/
|
|
||||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
|
||||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
|
||||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
|
||||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
|
||||||
spanFirstDay(zone) != spanLastDay(zone)
|
|
||||||
|
|
||||||
data class EventDetail(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.isoDayNumber
|
|
||||||
import kotlinx.datetime.minus
|
|
||||||
import kotlinx.datetime.number
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
|
||||||
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
|
|
||||||
* kept to the shapes the picker can build; the provider stays the authority.
|
|
||||||
*
|
|
||||||
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
|
|
||||||
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
|
|
||||||
* start day doesn't exist in rather than clamping; a weekly rule repeats in
|
|
||||||
* blocks of `interval` weeks beginning on Monday (the default WKST, since
|
|
||||||
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
|
|
||||||
* [RecurrenceEnd.Until] is inclusive.
|
|
||||||
*
|
|
||||||
* Returns fewer than [limit] dates when the series ends first, and an empty list
|
|
||||||
* only when the rule yields nothing at all (an UNTIL before [start]).
|
|
||||||
*/
|
|
||||||
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
|
||||||
if (limit <= 0) return emptyList()
|
|
||||||
val until = (end as? RecurrenceEnd.Until)?.date
|
|
||||||
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
|
||||||
val wanted = minOf(limit, maxCount)
|
|
||||||
if (wanted <= 0) return emptyList()
|
|
||||||
if (until != null && start > until) return emptyList()
|
|
||||||
|
|
||||||
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
|
|
||||||
// and let the walk skip anything landing on or before it.
|
|
||||||
val result = mutableListOf(start)
|
|
||||||
var period = 0
|
|
||||||
// Periods can yield nothing (a skipped 31st), so the cap counts periods
|
|
||||||
// examined rather than dates found.
|
|
||||||
while (result.size < wanted && period < MAX_PERIODS) {
|
|
||||||
for (date in occurrencesInPeriod(period, start)) {
|
|
||||||
if (date <= start) continue
|
|
||||||
if (until != null && date > until) return result
|
|
||||||
result += date
|
|
||||||
if (result.size == wanted) return result
|
|
||||||
}
|
|
||||||
period++
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
|
||||||
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
|
||||||
when (freq) {
|
|
||||||
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
|
||||||
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
|
||||||
RecurrenceFreq.Monthly -> {
|
|
||||||
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
|
||||||
// plus() clamps into the shorter month, but the rule skips such a
|
|
||||||
// period — so a clamped date means "not this month".
|
|
||||||
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
|
||||||
}
|
|
||||||
RecurrenceFreq.Yearly ->
|
|
||||||
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One weekly block: every picked weekday inside the week that begins
|
|
||||||
* `period * interval` weeks after the start's own week, in weekday order. With
|
|
||||||
* no picks the rule simply repeats the start's weekday.
|
|
||||||
*/
|
|
||||||
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
|
||||||
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
|
||||||
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
|
||||||
DAYS_PER_WEEK
|
|
||||||
val weekStart = start
|
|
||||||
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
|
||||||
.plus(period * interval, DateTimeUnit.WEEK)
|
|
||||||
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
|
||||||
weekStart.plus(
|
|
||||||
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
|
||||||
DateTimeUnit.DAY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a run of [occurrences] starting at [start] leaves its starting year,
|
|
||||||
* i.e. whether showing them without a year would be ambiguous — a yearly rule
|
|
||||||
* would otherwise read as the same date repeated.
|
|
||||||
*/
|
|
||||||
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
|
||||||
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
|
||||||
|
|
||||||
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
|
||||||
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
|
||||||
runCatching { LocalDate(year, month, day) }.getOrNull()
|
|
||||||
|
|
||||||
private const val DAYS_PER_WEEK = 7
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many repetitions to examine before giving up: generous enough for the
|
|
||||||
* sparsest rule the picker can build, bounded so a rule whose occurrences all
|
|
||||||
* fall outside its own UNTIL can't spin.
|
|
||||||
*/
|
|
||||||
private const val MAX_PERIODS = 2_000
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.reminders
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
|
|
||||||
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
|
|
||||||
data class ReminderEventInstance(
|
|
||||||
val eventId: Long,
|
|
||||||
val calendarId: Long,
|
|
||||||
val beginMillis: Long,
|
|
||||||
val endMillis: Long,
|
|
||||||
val title: String,
|
|
||||||
val location: String?,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One occurrence paired with one of its reminder offsets, and the instant that
|
|
||||||
* pairing has to fire at.
|
|
||||||
*/
|
|
||||||
data class PlannedReminder(
|
|
||||||
val instance: ReminderEventInstance,
|
|
||||||
val minutes: Int,
|
|
||||||
val alarmMillis: Long,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
|
||||||
var h = eventId * 1_000_003L
|
|
||||||
h = (h xor beginMillis) * 31L
|
|
||||||
return h + minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
|
||||||
data class ReminderSchedule(
|
|
||||||
val due: List<PlannedReminder>,
|
|
||||||
val nextAlarmMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val MILLIS_PER_MINUTE = 60_000L
|
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pair every instance with each of its event's reminder offsets.
|
|
||||||
*
|
|
||||||
* Timed occurrences fire at `begin − minutes`. All-day ones read the offset only
|
|
||||||
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
|
||||||
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
|
||||||
* Duplicate offsets in [minutesByEvent] collapse.
|
|
||||||
*/
|
|
||||||
fun planReminders(
|
|
||||||
instances: List<ReminderEventInstance>,
|
|
||||||
minutesByEvent: Map<Long, List<Int>>,
|
|
||||||
zone: ZoneId,
|
|
||||||
allDayTimeMinutes: Int,
|
|
||||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
|
||||||
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
|
||||||
PlannedReminder(
|
|
||||||
instance = instance,
|
|
||||||
minutes = minutes,
|
|
||||||
alarmMillis = if (instance.isAllDay) {
|
|
||||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
|
||||||
} else {
|
|
||||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
|
||||||
private fun allDayDate(beginMillis: Long): LocalDate =
|
|
||||||
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many whole days before its occurrence a raw all-day offset means.
|
|
||||||
*
|
|
||||||
* Our own rows fold the wanted hour into the offset, so the local date of the
|
|
||||||
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
|
|
||||||
* time and taken at face value instead — unless the instant lands on the hour the
|
|
||||||
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
|
|
||||||
* encodings collide and the tie goes to our own reading.
|
|
||||||
*
|
|
||||||
* Also used by
|
|
||||||
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
|
|
||||||
* display, so screen and notification agree.
|
|
||||||
*/
|
|
||||||
internal fun allDayLeadDays(
|
|
||||||
rawMinutes: Int,
|
|
||||||
startDate: LocalDate,
|
|
||||||
zone: ZoneId,
|
|
||||||
allDayTimeMinutes: Int,
|
|
||||||
): Long {
|
|
||||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
|
||||||
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
|
||||||
val minutesFromNamedHour = encoded.toLocalTime().let {
|
|
||||||
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
|
||||||
minOf(delta, MINUTES_PER_DAY - delta)
|
|
||||||
}
|
|
||||||
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
|
||||||
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
|
||||||
}
|
|
||||||
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
|
||||||
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
|
||||||
|
|
||||||
private fun allDayAlarmMillis(
|
|
||||||
beginMillis: Long,
|
|
||||||
rawMinutes: Int,
|
|
||||||
zone: ZoneId,
|
|
||||||
allDayTimeMinutes: Int,
|
|
||||||
): Long = allDayDate(beginMillis)
|
|
||||||
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
|
||||||
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
|
||||||
.atZone(zone)
|
|
||||||
.toInstant()
|
|
||||||
.toEpochMilli()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split [planned] into what is due now and when to wake up next.
|
|
||||||
*
|
|
||||||
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
|
||||||
* running twice cannot post twice while a late one still catches up. Reminders
|
|
||||||
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
|
||||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
|
||||||
*/
|
|
||||||
fun scheduleReminders(
|
|
||||||
planned: List<PlannedReminder>,
|
|
||||||
lastFiredMillis: Long,
|
|
||||||
nowMillis: Long,
|
|
||||||
horizonMillis: Long,
|
|
||||||
): ReminderSchedule {
|
|
||||||
val due = planned
|
|
||||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
|
||||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
|
||||||
.distinctBy { it.key }
|
|
||||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
|
||||||
val nextPending = planned
|
|
||||||
.filter { it.alarmMillis > nowMillis }
|
|
||||||
.minOfOrNull { it.alarmMillis }
|
|
||||||
return ReminderSchedule(
|
|
||||||
due = due,
|
|
||||||
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Still worth showing while the occurrence has not ended. Falls back to the
|
|
||||||
* begin time when the end is unknown (0L).
|
|
||||||
*/
|
|
||||||
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
|
||||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
|
|
||||||
* claims the present rather than replaying everything since the epoch; a
|
|
||||||
* watermark in the future (clock moved back) is clamped so it can't silence
|
|
||||||
* every reminder until real time catches up.
|
|
||||||
*/
|
|
||||||
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
|
||||||
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far ahead instances must be queried: the plain lookahead plus the longest
|
|
||||||
* reminder offset, so a "two weeks before" is planned before it comes due. The
|
|
||||||
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
|
|
||||||
* largest row in the whole provider, and a nonsense one would otherwise make
|
|
||||||
* every scan expand every series over years.
|
|
||||||
*/
|
|
||||||
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
|
||||||
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
|
||||||
|
|
||||||
/** Longest reminder offset a scan stretches its query window for — one year. */
|
|
||||||
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
|
||||||
@@ -23,7 +23,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
@@ -149,10 +148,6 @@ fun CalendarHost(
|
|||||||
// over Settings and survives view switches.
|
// over Settings and survives view switches.
|
||||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Backup & restore (#69) — hoisted like the manager, being driven by the
|
|
||||||
// calendar list rather than by preferences. Reached from both.
|
|
||||||
var showBackup by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||||
@@ -215,7 +210,6 @@ fun CalendarHost(
|
|||||||
fun dismissCoveringOverlays() {
|
fun dismissCoveringOverlays() {
|
||||||
showSettings = false
|
showSettings = false
|
||||||
showCalendars = false
|
showCalendars = false
|
||||||
showBackup = false
|
|
||||||
detailKey = null
|
detailKey = null
|
||||||
editKey = null
|
editKey = null
|
||||||
importUri = null
|
importUri = null
|
||||||
@@ -299,8 +293,8 @@ fun CalendarHost(
|
|||||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
// so back there falls through to the system and exits the app.
|
// so back there falls through to the system and exits the app.
|
||||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||||
editKey != null || showSettings || showCalendars || showBackup ||
|
editKey != null || showSettings || showCalendars || importUri != null ||
|
||||||
importUri != null || importForm != null
|
importForm != null
|
||||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||||
viewStack = viewStack.dropLast(1)
|
viewStack = viewStack.dropLast(1)
|
||||||
}
|
}
|
||||||
@@ -421,7 +415,6 @@ fun CalendarHost(
|
|||||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
onManageCalendars = { showCalendars = true },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,7 +434,6 @@ fun CalendarHost(
|
|||||||
editKey = null
|
editKey = null
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
onManageCalendars = { showCalendars = true },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,7 +447,18 @@ fun CalendarHost(
|
|||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenBackup = { showBackup = true },
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar manager — slides over Settings.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showCalendars,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
CalendarsScreen(
|
||||||
|
onBack = { showCalendars = false },
|
||||||
|
onImport = { importUri = it; importForceMany = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +469,6 @@ fun CalendarHost(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
onManageCalendars = { showCalendars = true },
|
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
@@ -481,40 +483,6 @@ fun CalendarHost(
|
|||||||
initialFormSource = importFormSource,
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
onManageCalendars = { showCalendars = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(),
|
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
|
||||||
) {
|
|
||||||
CalendarsScreen(
|
|
||||||
onBack = { showCalendars = false },
|
|
||||||
onOpenBackup = { showBackup = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup & restore — over the manager, since the manager links into it.
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showBackup,
|
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
|
||||||
) {
|
|
||||||
BackupScreen(
|
|
||||||
onBack = { showBackup = false },
|
|
||||||
// Restore runs the normal .ics import, and both this screen and
|
|
||||||
// the manager that can have opened it are declared above the
|
|
||||||
// import overlays — so both have to step aside.
|
|
||||||
onImport = {
|
|
||||||
importUri = it
|
|
||||||
importForceMany = true
|
|
||||||
showBackup = false
|
|
||||||
showCalendars = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -22,8 +21,6 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
@@ -51,10 +48,6 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A launch scan already covers the app coming up with the permission, so
|
|
||||||
// only a grant made during this session owes a re-scan.
|
|
||||||
val grantedAtLaunch = remember { hasPermission }
|
|
||||||
|
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
val obs = LifecycleEventObserver { _, event ->
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
@@ -85,19 +78,6 @@ fun RootScreen(
|
|||||||
// frame instead of flashing the wrong screen.
|
// frame instead of flashing the wrong screen.
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
// One-time explainer for the switch to the device's own calendar
|
|
||||||
// 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 via
|
|
||||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
visibilityNotice.reconcile()
|
|
||||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
|
||||||
}
|
|
||||||
if (onboardingDone == true && noticePending) {
|
|
||||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
|
||||||
}
|
|
||||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
when (done) {
|
when (done) {
|
||||||
true -> CalendarHost(
|
true -> CalendarHost(
|
||||||
|
|||||||
@@ -92,27 +92,20 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
|||||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||||
* year shown too only when it differs from the end'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(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
start: LocalDate,
|
start: LocalDate,
|
||||||
end: LocalDate,
|
end: LocalDate,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
monthAsSpan: Boolean = false,
|
|
||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
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 javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
return when {
|
return when (range) {
|
||||||
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
|
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
range == AgendaRange.ThisMonth && !monthAsSpan ->
|
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
|
||||||
else -> {
|
else -> {
|
||||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ fun AgendaScreen(
|
|||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -199,7 +198,6 @@ fun AgendaScreen(
|
|||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
description = stringResource(R.string.agenda_range_override_hint),
|
description = stringResource(R.string.agenda_range_override_hint),
|
||||||
selected = successState?.range ?: AgendaRange.Month,
|
selected = successState?.range ?: AgendaRange.Month,
|
||||||
weekStart = weekStart,
|
|
||||||
onSelect = viewModel::setRangeOverride,
|
onSelect = viewModel::setRangeOverride,
|
||||||
onDismiss = { showRangePicker = false },
|
onDismiss = { showRangePicker = false },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,14 +2,41 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
|
||||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone the 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 but UTC shifts the boundaries — east of UTC that leaks
|
||||||
|
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||||
|
*/
|
||||||
|
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||||
|
if (isAllDay) TimeZone.UTC else zone
|
||||||
|
|
||||||
|
/** The first calendar day this event occupies. */
|
||||||
|
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 [end].
|
||||||
|
*/
|
||||||
|
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||||
|
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||||
|
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||||
|
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||||
|
spanFirstDay(zone) != spanLastDay(zone)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an agenda row's time line should convey for an event on a given day —
|
* What an agenda row's time line should convey for an event on a given day —
|
||||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||||
|
|||||||
@@ -48,12 +48,8 @@ class AgendaViewModel @Inject constructor(
|
|||||||
settingsPrefs.agendaShowRangeBar,
|
settingsPrefs.agendaShowRangeBar,
|
||||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
/**
|
// First day of the week, for the calendar-aligned "this week" range.
|
||||||
* First day of the week, for the calendar-aligned "this week" range. Public
|
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
* because the range picker resolves each option to real dates, which needs
|
|
||||||
* the same week start the window is built from.
|
|
||||||
*/
|
|
||||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How to treat events that already ended today (show / dim / hide). A display
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
@@ -94,7 +90,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> =
|
val state: StateFlow<AgendaUiState> =
|
||||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||||
AgendaParams(
|
AgendaParams(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
range = override ?: settings.range,
|
range = override ?: settings.range,
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
|
||||||
import androidx.compose.material.icons.filled.FileUpload
|
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
|
||||||
// under several mimes, so accept the common set.
|
|
||||||
private val RESTORE_MIME_TYPES = arrayOf(
|
|
||||||
"text/calendar",
|
|
||||||
"application/octet-stream",
|
|
||||||
"text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
|
||||||
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
|
||||||
*
|
|
||||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
|
||||||
* [onImport] hands a picked file to the app's normal .ics import flow.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BackupScreen(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onImport: (Uri) -> Unit,
|
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
|
|
||||||
// Export covers local calendars only; managed special-dates mirrors are
|
|
||||||
// rebuilt from contacts. Restore can target anything the import picker offers.
|
|
||||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
|
||||||
val canImport = calendars.any { it.isEventTarget }
|
|
||||||
|
|
||||||
// Exports everything eligible (null); the per-calendar selector owns its
|
|
||||||
// own launcher.
|
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
|
||||||
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
|
||||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Restore runs the picked file through the normal .ics import flow.
|
|
||||||
val openBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
) { uri -> uri?.let(onImport) }
|
|
||||||
|
|
||||||
// The VM persists the write grant so background runs can keep writing.
|
|
||||||
val pickFolder = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
|
||||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
|
||||||
var showInterval by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
|
||||||
LaunchedEffect(backupResult) {
|
|
||||||
when (val r = backupResult) {
|
|
||||||
is BackupResult.Success -> {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
context.resources.getQuantityString(
|
|
||||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
viewModel.consumeBackupResult()
|
|
||||||
}
|
|
||||||
BackupResult.Failure -> {
|
|
||||||
snackbarHostState.showSnackbar(backupFailedText)
|
|
||||||
viewModel.consumeBackupResult()
|
|
||||||
}
|
|
||||||
null -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.settings_section_backup),
|
|
||||||
onBack = onBack,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
|
||||||
|
|
||||||
if (exportable.isNotEmpty()) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
|
||||||
position = Position.Top,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
|
||||||
onClick = {
|
|
||||||
// A single exportable calendar skips the selector.
|
|
||||||
if (exportable.size == 1) {
|
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
|
||||||
} else {
|
|
||||||
showExportPicker = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
summary = stringResource(R.string.calendars_restore_hint),
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
|
||||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
|
||||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
|
||||||
)
|
|
||||||
if (autoBackup.enabled) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
|
||||||
summary = rememberFolderName(autoBackup.folderUri)
|
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
|
||||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showInterval = true },
|
|
||||||
)
|
|
||||||
HintText(backupStatusText(autoBackup.status))
|
|
||||||
}
|
|
||||||
} else if (canImport) {
|
|
||||||
// Nothing to back up, but restore is still possible — don't hide
|
|
||||||
// it behind export eligibility.
|
|
||||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
|
||||||
HintText(stringResource(R.string.calendars_restore_hint))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showExportPicker) {
|
|
||||||
ExportCalendarPicker(
|
|
||||||
calendars = exportable,
|
|
||||||
onExport = viewModel::exportBackup,
|
|
||||||
onDismiss = { showExportPicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showInterval) {
|
|
||||||
BackupIntervalDialog(
|
|
||||||
currentMinutes = autoBackup.intervalMinutes,
|
|
||||||
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
|
||||||
onDismiss = { showInterval = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
|
||||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
|
||||||
* the picked file with the chosen calendar ids.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ExportCalendarPicker(
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
onExport: (Uri, Set<Long>?) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// Deliberately not keyed on [calendars]: that list is observer-driven, so
|
|
||||||
// keying it would reset the user's de-selections on every provider re-emit.
|
|
||||||
var selected by rememberSaveable(
|
|
||||||
stateSaver = listSaver(
|
|
||||||
save = { it.toList() },
|
|
||||||
restore = { it.toSet() },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
mutableStateOf(calendars.map { it.id }.toSet())
|
|
||||||
}
|
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
onExport(uri, selected)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FullScreenPicker(
|
|
||||||
title = stringResource(R.string.calendars_export_title),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
) {
|
|
||||||
HintText(stringResource(R.string.calendars_export_hint))
|
|
||||||
calendars.forEachIndexed { index, calendar ->
|
|
||||||
val isSelected = calendar.id in selected
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
summary = calendar.description,
|
|
||||||
position = positionOf(index, calendars.size),
|
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
|
||||||
trailing = {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
|
||||||
},
|
|
||||||
enabled = selected.isNotEmpty(),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.calendars_export_action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
|
||||||
@Composable
|
|
||||||
private fun rememberFolderName(uriString: String?): String? {
|
|
||||||
val context = LocalContext.current
|
|
||||||
return remember(uriString) {
|
|
||||||
uriString?.let {
|
|
||||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
|
||||||
@Composable
|
|
||||||
private fun backupIntervalLabel(minutes: Long): String {
|
|
||||||
val duration = when {
|
|
||||||
minutes % MINUTES_PER_WEEK == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
|
||||||
minutes % MINUTES_PER_DAY == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
|
||||||
minutes % 60L == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
|
||||||
else ->
|
|
||||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
|
||||||
}
|
|
||||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
|
||||||
@Composable
|
|
||||||
private fun backupStatusText(status: BackupStatus): String {
|
|
||||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
|
||||||
val relative = DateUtils.getRelativeTimeSpanString(
|
|
||||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
|
||||||
).toString()
|
|
||||||
return if (status.lastSuccess) {
|
|
||||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
|
||||||
@Composable
|
|
||||||
private fun BackupIntervalDialog(
|
|
||||||
currentMinutes: Long,
|
|
||||||
onConfirm: (Long) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// Pick the largest unit the current value divides into.
|
|
||||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
|
||||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
|
||||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
|
||||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
|
||||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
|
||||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
|
||||||
onDismiss()
|
|
||||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MINUTES_PER_DAY = 1_440L
|
|
||||||
private const val MINUTES_PER_WEEK = 10_080L
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one-time notice that Calendula now follows the device's per-calendar
|
|
||||||
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
|
||||||
* switch those calendars back on — that would un-hide them everywhere else too.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
private val reconciler: CalendarVisibilityReconciler,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconcile whenever the app comes up with the calendar permission held,
|
|
||||||
* rather than off one grant route: a permission granted on Android's
|
|
||||||
* app-settings screen never reaches the permission screen's callback.
|
|
||||||
*/
|
|
||||||
fun reconcile() {
|
|
||||||
viewModelScope.launch { reconciler.run() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
|
||||||
.map { it == true }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun dismiss() {
|
|
||||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
|
||||||
@Composable
|
|
||||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
|
||||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
|
||||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,9 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -23,22 +26,25 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Backup
|
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -60,68 +66,83 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
|
|
||||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
|
||||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
|
||||||
import de.jeanlucmakiola.calendula.domain.stateLabels
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
|
|
||||||
|
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||||
|
// mimes depending on the source app (our own export uses text/calendar; others
|
||||||
|
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||||
|
// than hide valid backups behind an over-tight filter.
|
||||||
|
private val RESTORE_MIME_TYPES = arrayOf(
|
||||||
|
"text/calendar",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
* calendars with create / rename / recolor / delete, and synced calendars
|
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||||
* read-only with a per-account "manage in the source app" deep-link.
|
* and lists synced calendars read-only with a per-account "manage in the source
|
||||||
*
|
* app" deep-link — the app never touches a synced calendar's server. A
|
||||||
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
* full-screen destination; [onBack] pops it.
|
||||||
* screen only points at it. [onBack] pops the destination.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onImport: (android.net.Uri) -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
// [editorSession] bumps on every open so the editor's field state resets for
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -137,7 +158,6 @@ fun CalendarsScreen(
|
|||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
if (id == null || id == NEW_CALENDAR_ID) {
|
if (id == null || id == NEW_CALENDAR_ID) {
|
||||||
@@ -157,14 +177,22 @@ fun CalendarsScreen(
|
|||||||
CalendarsList(
|
CalendarsList(
|
||||||
local = calendars.filter { it.isLocal },
|
local = calendars.filter { it.isLocal },
|
||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
|
disabledIds = disabledIds,
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
onOpenBackup = onOpenBackup,
|
backupResult = backupResult,
|
||||||
|
onExportBackup = viewModel::exportBackup,
|
||||||
|
onImport = onImport,
|
||||||
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
|
autoBackup = autoBackup,
|
||||||
|
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||||
|
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
onSetVisible = viewModel::setCalendarVisible,
|
onSetDisabled = viewModel::setDisabled,
|
||||||
onSetAccountVisible = viewModel::setAccountVisible,
|
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,20 +201,28 @@ fun CalendarsScreen(
|
|||||||
private fun CalendarsList(
|
private fun CalendarsList(
|
||||||
local: List<CalendarSource>,
|
local: List<CalendarSource>,
|
||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
|
disabledIds: Set<Long>,
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
backupResult: BackupResult?,
|
||||||
|
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
||||||
|
onImport: (android.net.Uri) -> Unit,
|
||||||
|
onConsumeBackupResult: () -> Unit,
|
||||||
|
autoBackup: AutoBackupUiState,
|
||||||
|
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||||
|
onSetAutoBackupInterval: (Long) -> Unit,
|
||||||
|
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
onSetVisible: (Long, Boolean) -> Unit,
|
onSetDisabled: (Long, Boolean) -> Unit,
|
||||||
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||||
// calendar visible by default, the section is collapsible for tidiness).
|
// calendar visible by default, the section is collapsible for tidiness).
|
||||||
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
|
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
||||||
var localExpanded by remember { mutableStateOf(true) }
|
var localExpanded by remember { mutableStateOf(true) }
|
||||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
@@ -197,6 +233,47 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
|
// to the VM to stream the .ics into. This launcher exports everything
|
||||||
|
// eligible (null); the per-calendar selector owns its own launcher.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let { onExportBackup(it, null) } }
|
||||||
|
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// SAF "open document" picker for restoring events from a .ics file. The
|
||||||
|
// picked Uri is handed up to the host, which runs it through the same import
|
||||||
|
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||||
|
val openBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> uri?.let(onImport) }
|
||||||
|
|
||||||
|
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||||
|
// write grant so background runs can keep writing to it.
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||||
|
var showInterval by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
|
LaunchedEffect(backupResult) {
|
||||||
|
when (val r = backupResult) {
|
||||||
|
is BackupResult.Success -> {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
BackupResult.Failure -> {
|
||||||
|
snackbarHostState.showSnackbar(backupFailedText)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -204,12 +281,12 @@ private fun CalendarsList(
|
|||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
|
|
||||||
// Local (device-only) calendars — one collapsible group. The header's
|
// Local (device-only) calendars — one collapsible group. The header's
|
||||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||||
// tapping a calendar row opens its editor.
|
// tapping a calendar row opens its editor.
|
||||||
val localDisabled = local.isNotEmpty() && local.none { it.isVisibleInSystem }
|
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = stringResource(R.string.calendars_local_header),
|
title = stringResource(R.string.calendars_local_header),
|
||||||
expanded = localExpanded,
|
expanded = localExpanded,
|
||||||
@@ -221,17 +298,17 @@ private fun CalendarsList(
|
|||||||
onManage = onAdd,
|
onManage = onAdd,
|
||||||
onToggleExpand = { localExpanded = !localExpanded },
|
onToggleExpand = { localExpanded = !localExpanded },
|
||||||
showToggleAll = local.isNotEmpty(),
|
showToggleAll = local.isNotEmpty(),
|
||||||
allEnabled = local.all { it.isVisibleInSystem },
|
allEnabled = local.none { it.id in disabledIds },
|
||||||
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||||
) {
|
) {
|
||||||
if (local.isEmpty()) {
|
if (local.isEmpty()) {
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
} else {
|
} else {
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendar.description,
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
@@ -240,7 +317,7 @@ private fun CalendarsList(
|
|||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName,
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onEdit(calendar) },
|
onClick = { onEdit(calendar) },
|
||||||
@@ -249,22 +326,82 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pointer to the Backup entry (#69), so it stays findable from here.
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
Spacer(Modifier.height(16.dp))
|
// safety net. Offered only when there is something exportable: the user's
|
||||||
GroupedRow(
|
// own local calendars (managed special-dates mirrors don't count).
|
||||||
title = stringResource(R.string.settings_section_backup),
|
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||||
summary = stringResource(R.string.settings_backup_subtitle),
|
// Restore/import can target any writable, non-managed calendar (local or
|
||||||
position = Position.Alone,
|
// synced), so its availability is broader than export's.
|
||||||
leading = { LeadingAvatar(Icons.Default.Backup) },
|
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
||||||
trailing = {
|
if (exportable.isNotEmpty()) {
|
||||||
Icon(
|
Spacer(Modifier.height(16.dp))
|
||||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
contentDescription = null,
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
// One connected card: the one-time export on top, then automatic
|
||||||
|
// backup (and its folder/interval rows when on).
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
// With more than one exportable calendar, let the user choose
|
||||||
|
// which to include; a single one exports straight away.
|
||||||
|
if (exportable.size == 1) {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = stringResource(R.string.calendars_restore_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = {
|
||||||
|
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
)
|
)
|
||||||
},
|
GroupedRow(
|
||||||
onClick = onOpenBackup,
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
)
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showInterval = true },
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
} else if (canImport) {
|
||||||
|
// Nothing to back up (no writable local calendar), but events can
|
||||||
|
// still be restored into a writable calendar — offer restore on its
|
||||||
|
// own so it isn't hidden behind export eligibility.
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(stringResource(R.string.calendars_restore_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -274,19 +411,14 @@ private fun CalendarsList(
|
|||||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
HintText(stringResource(R.string.calendars_synced_hint))
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
synced
|
synced
|
||||||
.groupByAccount()
|
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||||
.forEach { group ->
|
.forEach { (account, cals) ->
|
||||||
val cals = group.calendars
|
val expanded = account !in collapsedAccounts
|
||||||
val expanded = group.key !in collapsedAccounts
|
val accountType = cals.first().accountType
|
||||||
val accountType = group.accountType
|
val accountDisabled = cals.all { it.id in disabledIds }
|
||||||
// A non-syncing calendar has no switch, so it neither counts
|
|
||||||
// towards "the whole account is off" nor moves with toggle-all.
|
|
||||||
val switchable = cals.filter { it.hasVisibilitySwitch }
|
|
||||||
val accountDisabled = switchable.isNotEmpty() &&
|
|
||||||
switchable.none { it.isVisibleInSystem }
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = accountGroupTitle(group),
|
title = account,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
bodyHasRows = true,
|
bodyHasRows = true,
|
||||||
headerDisabled = accountDisabled,
|
headerDisabled = accountDisabled,
|
||||||
@@ -298,41 +430,29 @@ private fun CalendarsList(
|
|||||||
},
|
},
|
||||||
onToggleExpand = {
|
onToggleExpand = {
|
||||||
collapsedAccounts = if (expanded) {
|
collapsedAccounts = if (expanded) {
|
||||||
collapsedAccounts + group.key
|
collapsedAccounts + account
|
||||||
} else {
|
} else {
|
||||||
collapsedAccounts - group.key
|
collapsedAccounts - account
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showToggleAll = switchable.isNotEmpty(),
|
showToggleAll = true,
|
||||||
allEnabled = switchable.all { it.isVisibleInSystem },
|
allEnabled = cals.none { it.id in disabledIds },
|
||||||
onToggleAll = { enabled ->
|
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
||||||
onSetAccountVisible(switchable.map { it.id }, enabled)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
// Actionable calendars first; non-syncing ones at the
|
cals.forEachIndexed { index, calendar ->
|
||||||
// bottom, dimmed and switchless.
|
val disabled = calendar.id in disabledIds
|
||||||
val ordered = cals.orderedForManager()
|
|
||||||
ordered.forEachIndexed { index, calendar ->
|
|
||||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendarRowSummary(calendar),
|
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = if (calendar.hasVisibilitySwitch) {
|
trailing = {
|
||||||
{
|
EnableSwitch(
|
||||||
EnableSwitch(
|
calendarName = calendar.displayName,
|
||||||
calendarName = calendar.displayName,
|
enabled = !disabled,
|
||||||
enabled = calendar.isVisibleInSystem,
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
onToggle = { enabled ->
|
)
|
||||||
onSetVisible(calendar.id, enabled)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -340,6 +460,93 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = onSetAutoBackupInterval,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExportPicker) {
|
||||||
|
ExportCalendarPicker(
|
||||||
|
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
||||||
|
onExport = onExportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
|
* the picked file with the chosen calendar ids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExportCalendarPicker(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Seed once with everything selected and hold it across recomposition and
|
||||||
|
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||||
|
// it would silently reset the user's de-selections whenever the provider
|
||||||
|
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||||
|
// — the data layer intersects the chosen set with the eligible calendars.
|
||||||
|
var selected by rememberSaveable(
|
||||||
|
stateSaver = listSaver(
|
||||||
|
save = { it.toList() },
|
||||||
|
restore = { it.toSet() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
mutableStateOf(calendars.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
onExport(uri, selected)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.calendars_export_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_export_hint))
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id in selected
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, calendars.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
enabled = selected.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.calendars_export_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -353,7 +560,6 @@ private fun CalendarEditor(
|
|||||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
deleteLocked: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
@@ -387,20 +593,11 @@ private fun CalendarEditor(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
// Disabled rather than hidden while the special-dates
|
IconButton(onClick = { confirmDelete = true }) {
|
||||||
// sync owns this calendar; the card below says why.
|
|
||||||
IconButton(
|
|
||||||
onClick = { confirmDelete = true },
|
|
||||||
enabled = !deleteLocked,
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
tint = if (deleteLocked) {
|
tint = MaterialTheme.colorScheme.error,
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,19 +626,6 @@ private fun CalendarEditor(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
if (deleteLocked) {
|
|
||||||
EditorCard(
|
|
||||||
icon = Icons.Default.Info,
|
|
||||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
iconAtTop = true,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
@@ -514,28 +698,10 @@ private fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The row's supporting line: the states that make this calendar behave unlike a
|
* The per-row enable/disable control. Checked = the calendar is shown in the
|
||||||
* plain writable one (#76), then its own description.
|
* app; unchecking disables it (events, filters and pickers all drop it) without
|
||||||
*/
|
* touching any provider data. Carries its own content description so the toggle
|
||||||
@Composable
|
* is self-describing to screen readers even on a dimmed row.
|
||||||
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
|
||||||
val states = calendar.stateLabels().map { label ->
|
|
||||||
stringResource(
|
|
||||||
when (label) {
|
|
||||||
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
|
||||||
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
|
||||||
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
|
||||||
return parts.joinToString(" · ").ifEmpty { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
@Composable
|
||||||
private fun EnableSwitch(
|
private fun EnableSwitch(
|
||||||
@@ -543,7 +709,7 @@ private fun EnableSwitch(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onToggle: (Boolean) -> Unit,
|
onToggle: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
||||||
Switch(
|
Switch(
|
||||||
checked = enabled,
|
checked = enabled,
|
||||||
onCheckedChange = onToggle,
|
onCheckedChange = onToggle,
|
||||||
@@ -717,31 +883,113 @@ private fun CalendarGroupMenu(
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SectionHeader(text: String) {
|
private fun SectionHeader(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
// The cards' own edge, so header and group share a left margin.
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 4.dp,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HintText(text: String) {
|
private fun HintText(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberFolderName(uriString: String?): String? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(uriString) {
|
||||||
|
uriString?.let {
|
||||||
|
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||||
|
@Composable
|
||||||
|
private fun backupIntervalLabel(minutes: Long): String {
|
||||||
|
val duration = when {
|
||||||
|
minutes % MINUTES_PER_WEEK == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||||
|
minutes % MINUTES_PER_DAY == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||||
|
minutes % 60L == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||||
|
else ->
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||||
|
}
|
||||||
|
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||||
|
@Composable
|
||||||
|
private fun backupStatusText(status: BackupStatus): String {
|
||||||
|
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||||
|
val relative = DateUtils.getRelativeTimeSpanString(
|
||||||
|
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString()
|
||||||
|
return if (status.lastSuccess) {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||||
|
@Composable
|
||||||
|
private fun BackupIntervalDialog(
|
||||||
|
currentMinutes: Long,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||||
|
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||||
|
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||||
|
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||||
|
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||||
|
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||||
|
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||||
|
onDismiss()
|
||||||
|
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTES_PER_DAY = 1_440L
|
||||||
|
private const val MINUTES_PER_WEEK = 10_080L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the app to open for managing a synced calendar's account. The account's
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -42,7 +45,10 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val suppressedStore: SuppressedReminderStore,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -56,6 +62,20 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars the user has disabled in the app. This screen is the only
|
||||||
|
* surface that lists them, so it both reads the set (to dim the rows) and
|
||||||
|
* toggles it. Every other surface simply excludes these ids.
|
||||||
|
*/
|
||||||
|
val disabledCalendarIds: StateFlow<Set<Long>> =
|
||||||
|
prefs.disabledCalendarIds
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
@@ -72,25 +92,6 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = AutoBackupUiState(),
|
initialValue = AutoBackupUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
settingsPrefs.specialDatesEnabled,
|
|
||||||
) { sources, enabled ->
|
|
||||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
|
||||||
}
|
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -139,21 +140,54 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch a calendar on or off — the app's one visibility model, writing the
|
* Enable or disable a calendar app-side. Disabling removes it from every
|
||||||
* system's `Calendars.VISIBLE`. A reminder that came due while the calendar
|
* surface but Settings → Calendars (and hides its events) without touching
|
||||||
* was off stays gone when it is switched back on: the watermark has already
|
* provider data — purely a reversible Calendula-local view choice.
|
||||||
* moved past it (#75).
|
|
||||||
*/
|
*/
|
||||||
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
fun setDisabled(id: Long, disabled: Boolean) {
|
||||||
repository.setCalendarsVisible(listOf(id), visible)
|
viewModelScope.launch {
|
||||||
|
val current = prefs.disabledCalendarIds.first()
|
||||||
|
val next = if (disabled) current + id else current - id
|
||||||
|
if (next != current) {
|
||||||
|
prefs.setDisabledCalendarIds(next)
|
||||||
|
if (!disabled) recoverReminders(setOf(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch every calendar of one account on or off. Each row is written on its
|
* Enable or disable every calendar of one account in a single write — the
|
||||||
* own, in one coroutine so the writes can't race.
|
* "toggle all" affordance on an account header. Done as one set update so the
|
||||||
|
* per-calendar [setDisabled] calls can't race each other.
|
||||||
*/
|
*/
|
||||||
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||||
repository.setCalendarsVisible(ids, visible)
|
viewModelScope.launch {
|
||||||
|
val current = prefs.disabledCalendarIds.first()
|
||||||
|
val next = if (disabled) current + ids else current - ids.toSet()
|
||||||
|
if (next != current) {
|
||||||
|
prefs.setDisabledCalendarIds(next)
|
||||||
|
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||||
|
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||||
|
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||||
|
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||||
|
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||||
|
* unposted because those gates are closed are simply released.
|
||||||
|
*/
|
||||||
|
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||||
|
if (reEnabledIds.isEmpty()) return
|
||||||
|
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||||
|
if (recovered.isNotEmpty() &&
|
||||||
|
settingsPrefs.remindersEnabled.first() &&
|
||||||
|
notifier.canPost()
|
||||||
|
) {
|
||||||
|
recovered.forEach { notifier.post(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Automatic backup (issue #8) ------------------------------------
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One account's calendars, as every surface that lists calendars by account
|
|
||||||
* shows them. An account is identified by name **and** type (#77): a Google and
|
|
||||||
* a DAVx5 account can share an address and still be two separate accounts.
|
|
||||||
*/
|
|
||||||
data class CalendarAccountGroup(
|
|
||||||
/** Stable identity: what makes two calendars belong to the same account. */
|
|
||||||
val key: AccountKey,
|
|
||||||
/** The account's own name, as shown when it is unambiguous. */
|
|
||||||
val label: String,
|
|
||||||
/** True when another group shows the same [label] under a different type. */
|
|
||||||
val ambiguous: Boolean,
|
|
||||||
val calendars: List<CalendarSource>,
|
|
||||||
) {
|
|
||||||
val accountType: String get() = key.type
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The pair a group is keyed on. */
|
|
||||||
data class AccountKey(val name: String, val type: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group [calendars] under their owning account, preserving the provider's order
|
|
||||||
* within each group and ordering groups by first appearance.
|
|
||||||
*
|
|
||||||
* The label falls back through name → type → the first calendar's own name, so
|
|
||||||
* a calendar with no account still lands somewhere sensible.
|
|
||||||
*/
|
|
||||||
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
|
||||||
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
|
|
||||||
val labels = grouped.mapValues { (key, cals) ->
|
|
||||||
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
|
|
||||||
}
|
|
||||||
val shared = labels.values.groupingBy { it }.eachCount()
|
|
||||||
return grouped.map { (key, cals) ->
|
|
||||||
val label = labels.getValue(key)
|
|
||||||
CalendarAccountGroup(
|
|
||||||
key = key,
|
|
||||||
label = label,
|
|
||||||
ambiguous = shared.getValue(label) > 1,
|
|
||||||
calendars = cals,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What to write above a group: its account name, qualified with the app the
|
|
||||||
* account comes from when another account shares the name (#77).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
|
||||||
if (!group.ambiguous) {
|
|
||||||
group.label
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The human name of the app backing [accountType], falling back to the raw
|
|
||||||
* account type when no installed app resolves for it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun sourceAppName(accountType: String): String {
|
|
||||||
val context = LocalContext.current
|
|
||||||
return remember(accountType) {
|
|
||||||
val pm = context.packageManager
|
|
||||||
val packages = sourceAppPackages(context, accountType)
|
|
||||||
packages.firstNotNullOfOrNull { pkg ->
|
|
||||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
|
||||||
} ?: accountType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,8 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -44,21 +42,18 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
|
|||||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||||
*
|
|
||||||
* The list holds event *targets* only, so a switched-off, read-only or managed
|
|
||||||
* calendar is absent (#76). [onManageCalendars], when given, adds the footer row
|
|
||||||
* naming the possible reasons and opening the calendar manager.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ColumnScope.CalendarPickerGroups(
|
fun ColumnScope.CalendarPickerGroups(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
val syncedGroups = remember(calendars) {
|
val syncedGroups = remember(calendars) {
|
||||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
calendars.filterNot { it.isLocal }
|
||||||
|
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
||||||
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.isNotEmpty()) {
|
if (local.isNotEmpty()) {
|
||||||
@@ -70,33 +65,16 @@ fun ColumnScope.CalendarPickerGroups(
|
|||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
syncedGroups.forEachIndexed { index, group ->
|
syncedGroups.forEachIndexed { index, (account, cals) ->
|
||||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||||
CalendarPickerGroup(
|
CalendarPickerGroup(
|
||||||
title = accountGroupTitle(group),
|
title = account,
|
||||||
leading = { SourceLogo(group.accountType) },
|
leading = { SourceLogo(cals.first().accountType) },
|
||||||
calendars = group.calendars,
|
calendars = cals,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (onManageCalendars != null) {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendar_picker_missing_title),
|
|
||||||
summary = stringResource(R.string.calendar_picker_missing_summary),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
|
|
||||||
trailing = {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onManageCalendars,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||||
@@ -177,22 +155,20 @@ fun LeadingAvatar(icon: ImageVector) {
|
|||||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
for (pkg in sourceAppPackages(context, accountType)) {
|
val candidates = buildList {
|
||||||
|
curatedSourcePackage(accountType)?.let { add(it) }
|
||||||
|
AccountManager.get(context).authenticatorTypes
|
||||||
|
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||||
|
?.packageName
|
||||||
|
?.let { add(it) }
|
||||||
|
}
|
||||||
|
for (pkg in candidates) {
|
||||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||||
if (bitmap != null) return bitmap.asImageBitmap()
|
if (bitmap != null) return bitmap.asImageBitmap()
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apps that could stand for [accountType], best candidate first. */
|
|
||||||
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = buildList {
|
|
||||||
curatedSourcePackage(accountType)?.let { add(it) }
|
|
||||||
AccountManager.get(context).authenticatorTypes
|
|
||||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
|
||||||
?.packageName
|
|
||||||
?.let { add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
|||||||
@@ -23,26 +23,16 @@ import androidx.compose.ui.unit.dp
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
@@ -58,10 +48,6 @@ import kotlin.time.Clock
|
|||||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
* back.
|
* back.
|
||||||
*
|
|
||||||
* [leadTimeSummary] adds a second line to each lead-time row, for the all-day
|
|
||||||
* pickers: the hour comes from the separate "show all-day reminders at" setting,
|
|
||||||
* so the lead time alone doesn't say when anything happens.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -71,7 +57,6 @@ fun ReminderDefaultPicker(
|
|||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (ReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
leadTimeSummary: (@Composable (Int) -> String?)? = null,
|
|
||||||
) {
|
) {
|
||||||
// Optimistic local state: once the user edits, the chosen override is
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
// authoritative while the picker is open, so quick successive toggles compose
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
@@ -147,7 +132,6 @@ fun ReminderDefaultPicker(
|
|||||||
val checked = minute in selectedMinutes
|
val checked = minute in selectedMinutes
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderLeadTimeLabel(minute),
|
title = reminderLeadTimeLabel(minute),
|
||||||
summary = leadTimeSummary?.invoke(minute),
|
|
||||||
position = positionOf(index, rowCount),
|
position = positionOf(index, rowCount),
|
||||||
selected = checked,
|
selected = checked,
|
||||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||||
@@ -212,15 +196,14 @@ private fun CustomReminderEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
* on the same left margin as the rows themselves. */
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,17 +213,12 @@ internal fun PickerDescription(text: String) {
|
|||||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||||
*
|
|
||||||
* Every option carries the dates it resolves to today as its summary, computed
|
|
||||||
* through the same [dayCount] the agenda windows by (hence [weekStart]) — only
|
|
||||||
* the concrete span tells "This week" and "Next 7 days" apart.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaRangePicker(
|
fun AgendaRangePicker(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
selected: AgendaRange,
|
selected: AgendaRange,
|
||||||
weekStart: DayOfWeek,
|
|
||||||
onSelect: (AgendaRange) -> Unit,
|
onSelect: (AgendaRange) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -254,21 +232,10 @@ fun AgendaRangePicker(
|
|||||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
val locale = currentLocale()
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
|
||||||
// The agenda's own end-day arithmetic (anchor + dayCount - 1), so the dates
|
|
||||||
// match what it will show.
|
|
||||||
val windowSummary: (AgendaRange) -> String = { range ->
|
|
||||||
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
|
||||||
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = agendaRangeLabel(option),
|
title = agendaRangeLabel(option),
|
||||||
summary = windowSummary(option),
|
|
||||||
position = position,
|
position = position,
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
@@ -303,9 +270,6 @@ fun AgendaRangePicker(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.agenda_range_custom)
|
stringResource(R.string.agenda_range_custom)
|
||||||
},
|
},
|
||||||
// An unset custom window has no day count yet, so the row stays
|
|
||||||
// single-line until it does.
|
|
||||||
summary = if (customSelected) windowSummary(selected) else null,
|
|
||||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||||
selected = customSelected,
|
selected = customSelected,
|
||||||
trailing = if (customSelected) {
|
trailing = if (customSelected) {
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ import androidx.compose.ui.text.SpanStyle
|
|||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
|
||||||
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
|
||||||
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -103,33 +98,6 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
|
|
||||||
* the recurrence picker, where [recurrenceText]'s phrase says what the rule is
|
|
||||||
* and this says what it does. Expands via [upcomingOccurrences] and inherits its
|
|
||||||
* limits. A rule that yields nothing says so rather than showing an empty list.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun nextOccurrencesText(
|
|
||||||
rule: SimpleRecurrence,
|
|
||||||
start: kotlinx.datetime.LocalDate,
|
|
||||||
locale: Locale,
|
|
||||||
): String {
|
|
||||||
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
|
||||||
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
|
||||||
// Years only once they carry information (see [spansMultipleYears]).
|
|
||||||
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
|
|
||||||
val formatter = localizedDateFormatter(locale, pattern)
|
|
||||||
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
|
|
||||||
return stringResource(
|
|
||||||
R.string.event_edit_recurrence_next,
|
|
||||||
ListFormatter.getInstance(locale).format(formatted),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
|
|
||||||
private const val NEXT_OCCURRENCE_COUNT = 3
|
|
||||||
|
|
||||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
val dow = when (token.takeLast(2).uppercase()) {
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -241,12 +240,7 @@ private fun SectionHeader(text: String) {
|
|||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 4.dp,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -157,7 +155,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -196,7 +193,6 @@ fun EventEditScreen(
|
|||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
@@ -313,7 +309,6 @@ fun EventEditScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
onManageCalendars = onManageCalendars,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,7 +500,6 @@ private fun EventEditContent(
|
|||||||
state: EventEditUiState,
|
state: EventEditUiState,
|
||||||
viewModel: EventEditViewModel,
|
viewModel: EventEditViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
@@ -515,10 +509,6 @@ private fun EventEditContent(
|
|||||||
// they're locked here; everything else (reminders, location, notes) is the
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
// user's to edit.
|
// user's to edit.
|
||||||
val locked = state.isManaged
|
val locked = state.isManaged
|
||||||
// Read in the form's own window, not the picker's: the focused field lives
|
|
||||||
// here, so this is the controller that can put its keyboard away.
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -1124,15 +1114,6 @@ private fun EventEditContent(
|
|||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
// A full-screen picker is a change of place, so the keyboard shouldn't
|
|
||||||
// follow it. The field keeps its text; only focus and the IME go.
|
|
||||||
LaunchedEffect(showCalendarPicker) {
|
|
||||||
if (showCalendarPicker) {
|
|
||||||
focusManager.clearFocus(force = true)
|
|
||||||
keyboardController?.hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCalendarPicker) {
|
if (showCalendarPicker) {
|
||||||
CalendarPicker(
|
CalendarPicker(
|
||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
@@ -1141,15 +1122,6 @@ private fun EventEditContent(
|
|||||||
viewModel.setCalendar(it)
|
viewModel.setCalendar(it)
|
||||||
showCalendarPicker = false
|
showCalendarPicker = false
|
||||||
},
|
},
|
||||||
// Close the picker first: it is a Compose Dialog in its own window,
|
|
||||||
// always above the activity's content, so the manager would
|
|
||||||
// otherwise open behind it and the tap would look dead.
|
|
||||||
onManageCalendars = onManageCalendars?.let { openManager ->
|
|
||||||
{
|
|
||||||
showCalendarPicker = false
|
|
||||||
openManager()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = { showCalendarPicker = false },
|
onDismiss = { showCalendarPicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1169,7 +1141,7 @@ private fun EventEditContent(
|
|||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
startDate = form.start.date,
|
startDay = form.start.date.dayOfWeek,
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
onSelect = { rrule ->
|
onSelect = { rrule ->
|
||||||
viewModel.setRecurrence(rrule)
|
viewModel.setRecurrence(rrule)
|
||||||
@@ -1337,20 +1309,15 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
|||||||
* and an end condition — only that step needs an OK button. A rule the
|
* and an end condition — only that step needs an OK button. A rule the
|
||||||
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||||
* user picks something here.
|
* user picks something here.
|
||||||
*
|
|
||||||
* Both steps show the rule as *dates* as well as words: every preset row
|
|
||||||
* carries the next few occurrences it would produce from [startDate], and the
|
|
||||||
* custom step repeats that under its live read-out.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecurrencePickerDialog(
|
private fun RecurrencePickerDialog(
|
||||||
current: String?,
|
current: String?,
|
||||||
startDate: LocalDate,
|
startDay: DayOfWeek,
|
||||||
firstDayOfWeek: DayOfWeek,
|
firstDayOfWeek: DayOfWeek,
|
||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val startDay = startDate.dayOfWeek
|
|
||||||
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||||
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||||
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||||
@@ -1402,19 +1369,16 @@ private fun RecurrencePickerDialog(
|
|||||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||||
}
|
}
|
||||||
// Kept as the rule object, not just its RRULE text: the read-out and the
|
val customResult: String? = if (interval != null && customEnd != null) {
|
||||||
// date list must describe the one thing OK would save.
|
|
||||||
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
|
|
||||||
SimpleRecurrence(
|
SimpleRecurrence(
|
||||||
freq = freq,
|
freq = freq,
|
||||||
interval = interval,
|
interval = interval,
|
||||||
end = customEnd,
|
end = customEnd,
|
||||||
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||||
)
|
).toRRule()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val customResult: String? = customRule?.toRRule()
|
|
||||||
|
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_recurrence),
|
title = stringResource(R.string.event_detail_recurrence),
|
||||||
@@ -1445,7 +1409,6 @@ private fun RecurrencePickerDialog(
|
|||||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(recurrencePresetLabel(entry)),
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
|
|
||||||
position = positionOf(index + 1, rowCount),
|
position = positionOf(index + 1, rowCount),
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||||
@@ -1497,17 +1460,6 @@ private fun RecurrencePickerDialog(
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rendered even while incomplete (as an empty line), so the
|
|
||||||
// controls below don't shift as it comes and goes.
|
|
||||||
Text(
|
|
||||||
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
// How often: an interval amount plus a frequency segmented row —
|
// How often: an interval amount plus a frequency segmented row —
|
||||||
// all four units on show, no unit hidden behind a dropdown.
|
// all four units on show, no unit hidden behind a dropdown.
|
||||||
GroupedSurface(
|
GroupedSurface(
|
||||||
@@ -2024,9 +1976,6 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
|||||||
/**
|
/**
|
||||||
* Visibility selector: one card per level, each with its own icon; the
|
* Visibility selector: one card per level, each with its own icon; the
|
||||||
* current level is highlighted. Tap picks and closes.
|
* current level is highlighted. Tap picks and closes.
|
||||||
*
|
|
||||||
* Every level carries a line saying who this affects — the labels alone don't
|
|
||||||
* say that visibility is about what others on a shared calendar see.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun VisibilityPickerDialog(
|
private fun VisibilityPickerDialog(
|
||||||
@@ -2039,7 +1988,6 @@ private fun VisibilityPickerDialog(
|
|||||||
options = AccessLevel.entries.toList(),
|
options = AccessLevel.entries.toList(),
|
||||||
selected = selected,
|
selected = selected,
|
||||||
label = { stringResource(accessLevelLabel(it)) },
|
label = { stringResource(accessLevelLabel(it)) },
|
||||||
summary = { stringResource(accessLevelSummary(it)) },
|
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||||
@@ -2132,14 +2080,6 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
|||||||
AccessLevel.Confidential -> R.string.event_access_confidential
|
AccessLevel.Confidential -> R.string.event_access_confidential
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What each level means for people the calendar is shared with. */
|
|
||||||
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
|
||||||
AccessLevel.Default -> R.string.event_access_default_summary
|
|
||||||
AccessLevel.Public -> R.string.event_access_public_summary
|
|
||||||
AccessLevel.Private -> R.string.event_access_private_summary
|
|
||||||
AccessLevel.Confidential -> R.string.event_access_confidential_summary
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
@@ -2337,7 +2277,6 @@ private fun CalendarPicker(
|
|||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_calendar),
|
title = stringResource(R.string.event_detail_calendar),
|
||||||
@@ -2347,7 +2286,6 @@ private fun CalendarPicker(
|
|||||||
calendars = calendars,
|
calendars = calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
onManageCalendars = onManageCalendars,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
@@ -178,16 +177,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
repository.calendars().catch { emit(emptyList()) }
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
* A last-used preselect landing on an excluded calendar falls back to the
|
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||||
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||||
*
|
* Managed special-dates calendars are excluded too: their events are owned by
|
||||||
* This is the list of *targets*. An event already living in an excluded
|
* the contact sync, which would delete any user event created there.
|
||||||
* calendar keeps it — [state] adds it back to the picker.
|
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||||
calendars.filter { it.isEventTarget }
|
allCalendars,
|
||||||
|
prefs.disabledCalendarIds,
|
||||||
|
) { calendars, disabled ->
|
||||||
|
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
@@ -233,14 +234,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// off the calendar's durable marker, not a stored id, so it holds after a
|
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||||
// backup restore too.
|
// backup restore too.
|
||||||
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||||
// The event's own calendar is added back whenever it isn't a target
|
// The picker offers writable calendars only; when editing a managed event
|
||||||
// (managed, or switched off), so the row keeps naming it and saving can
|
// its own (excluded) calendar is added back so the row still names it.
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
val pickerCalendars =
|
val pickerCalendars =
|
||||||
if (ownCalendar != null) external.writable + ownCalendar else external.writable
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
|
else external.writable
|
||||||
// An all-day event is date-anchored, so a zone is meaningless on it —
|
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||||
// the field is withheld from both lists rather than shown as a no-op.
|
// the field is withheld from both lists rather than shown as a no-op.
|
||||||
val offerableFields = EventFormField.entries.toSet() -
|
val offerableFields = EventFormField.entries.toSet() -
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
@@ -62,15 +61,7 @@ private fun FilterList(
|
|||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
Text(
|
Text(
|
||||||
text = if (group.ambiguous) {
|
text = group.account,
|
||||||
stringResource(
|
|
||||||
R.string.calendars_account_from_source,
|
|
||||||
group.account,
|
|
||||||
sourceAppName(group.accountType),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
group.account
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
|||||||
@@ -13,17 +13,9 @@ sealed interface FilterUiState {
|
|||||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
||||||
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
|
||||||
*
|
|
||||||
* [accountType] and [ambiguous] carry what the header needs to tell two
|
|
||||||
* same-named accounts from different apps apart (#77); the label itself is
|
|
||||||
* built in the UI layer, which is where the source app's name can be looked up.
|
|
||||||
*/
|
|
||||||
data class AccountGroup(
|
data class AccountGroup(
|
||||||
val account: String,
|
val account: String,
|
||||||
val accountType: String,
|
|
||||||
val ambiguous: Boolean,
|
|
||||||
val calendars: List<CalendarRow>,
|
val calendars: List<CalendarRow>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -31,14 +30,16 @@ class FilterViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { calendars, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
// Calendars switched off device-wide don't belong in the drawer's
|
) { calendars, hidden, disabled ->
|
||||||
// hide/show list; they live only in Settings → Calendars.
|
// Disabled calendars are gone from the app entirely — they don't
|
||||||
val enabled = calendars.filter { it.isVisibleInSystem }
|
// belong in the drawer's hide/show list (you can't hide what's
|
||||||
|
// already disabled). They live only in Settings → Calendars.
|
||||||
|
val enabled = calendars.filterNot { it.id in disabled }
|
||||||
if (enabled.isEmpty()) {
|
if (enabled.isEmpty()) {
|
||||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
FilterUiState.Success(groupByAccount(enabled, hidden))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -60,27 +61,30 @@ class FilterViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group calendars under their owning account — by name *and* type, so two
|
* Group calendars under their owning account, preserving the provider's order
|
||||||
* accounts that merely share a name stay apart (#77) — preserving the
|
* within each group and ordering groups by first appearance. A calendar is
|
||||||
* provider's order within each group and ordering groups by first appearance.
|
* "visible" when its id is *not* in [hidden].
|
||||||
* A calendar is "visible" when its id is *not* in [hidden].
|
|
||||||
*/
|
*/
|
||||||
internal fun groupCalendarsForFilter(
|
internal fun groupByAccount(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
hidden: Set<Long>,
|
hidden: Set<Long>,
|
||||||
): List<AccountGroup> =
|
): List<AccountGroup> =
|
||||||
calendars.groupByAccount().map { group ->
|
calendars
|
||||||
AccountGroup(
|
.groupBy { it.accountLabel() }
|
||||||
account = group.label,
|
.map { (account, cals) ->
|
||||||
accountType = group.accountType,
|
AccountGroup(
|
||||||
ambiguous = group.ambiguous,
|
account = account,
|
||||||
calendars = group.calendars.map { c ->
|
calendars = cals.map { c ->
|
||||||
CalendarRow(
|
CalendarRow(
|
||||||
id = c.id,
|
id = c.id,
|
||||||
displayName = c.displayName,
|
displayName = c.displayName,
|
||||||
color = c.color,
|
color = c.color,
|
||||||
visible = c.id !in hidden,
|
visible = c.id !in hidden,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Account header text: the account name, falling back to its type. */
|
||||||
|
private fun CalendarSource.accountLabel(): String =
|
||||||
|
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ fun ImportScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
|
||||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||||
// across imports — its one-shot `load` guard would then show the *previous*
|
// across imports — its one-shot `load` guard would then show the *previous*
|
||||||
@@ -156,12 +155,7 @@ fun ImportScreen(
|
|||||||
|
|
||||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
is ImportUiState.Many -> ManyContent(
|
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
||||||
state = s,
|
|
||||||
selected = selected,
|
|
||||||
onSelect = { selected = it },
|
|
||||||
onManageCalendars = onManageCalendars,
|
|
||||||
)
|
|
||||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,22 +163,10 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManyContent(
|
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
||||||
state: ImportUiState.Many,
|
// No writable calendar to import into — tell the user honestly.
|
||||||
selected: Long?,
|
|
||||||
onSelect: (Long) -> Unit,
|
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
// No calendar to import into; carries the same way out the picker's footer
|
|
||||||
// offers below (#76).
|
|
||||||
if (state.calendars.isEmpty()) {
|
if (state.calendars.isEmpty()) {
|
||||||
CenteredMessage(
|
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||||
message = stringResource(R.string.import_no_calendar),
|
|
||||||
onClose = null,
|
|
||||||
actionLabel = stringResource(R.string.settings_manage_calendars)
|
|
||||||
.takeIf { onManageCalendars != null },
|
|
||||||
onAction = onManageCalendars,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +178,6 @@ private fun ManyContent(
|
|||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
selectedId = selected,
|
selectedId = selected,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
onManageCalendars = onManageCalendars,
|
|
||||||
)
|
)
|
||||||
if (state.warnings.isNotEmpty()) {
|
if (state.warnings.isNotEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -352,12 +333,7 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CenteredMessage(
|
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||||
message: String,
|
|
||||||
onClose: (() -> Unit)?,
|
|
||||||
actionLabel: String? = null,
|
|
||||||
onAction: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(24.dp),
|
Modifier.padding(24.dp),
|
||||||
@@ -368,9 +344,6 @@ private fun CenteredMessage(
|
|||||||
if (onClose != null) {
|
if (onClose != null) {
|
||||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
}
|
}
|
||||||
if (actionLabel != null && onAction != null) {
|
|
||||||
Button(onClick = onAction) { Text(actionLabel) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
@@ -14,7 +15,6 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -57,6 +57,7 @@ sealed interface ImportUiState {
|
|||||||
class ImportViewModel @Inject constructor(
|
class ImportViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val importer: IcsImporter,
|
private val importer: IcsImporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -86,12 +87,16 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// The same targets the event form offers ([isEventTarget]).
|
// A disabled calendar is removed from the app, so it can't be
|
||||||
|
// an import target — exclude it alongside the read-only ones.
|
||||||
|
// Managed special-dates calendars are contact-derived and
|
||||||
|
// editor-locked, so they're not a valid destination either.
|
||||||
|
val disabled = prefs.disabledCalendarIds.first()
|
||||||
ImportUiState.Many(
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter { it.isEventTarget },
|
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ import kotlinx.datetime.toJavaLocalDate
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -365,14 +364,10 @@ fun MonthScreen(
|
|||||||
todayText = stringResource(R.string.month_today_action),
|
todayText = stringResource(R.string.month_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
// Split has a selected day; the other styles anchor on
|
// Anchor on today when its month is shown, else the 1st.
|
||||||
// today when its month is shown, else the 1st.
|
|
||||||
onCreateEvent(
|
onCreateEvent(
|
||||||
when {
|
if (isOnCurrentMonth) today
|
||||||
viewStyle == MonthViewStyle.Split -> selectedDate
|
else LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
isOnCurrentMonth -> today
|
|
||||||
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
|
|
||||||
},
|
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1282,7 +1277,7 @@ internal fun SplitMonthGrid(
|
|||||||
SplitDayCell(
|
SplitDayCell(
|
||||||
date = day,
|
date = day,
|
||||||
events = seated,
|
events = seated,
|
||||||
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
hidden = (week.countByDay[day] ?: 0) - seated.size,
|
||||||
isToday = day == state.today,
|
isToday = day == state.today,
|
||||||
// A page marks only the days its own month owns. Paging
|
// A page marks only the days its own month owns. Paging
|
||||||
// moves the selection before this month's replacement
|
// moves the selection before this month's replacement
|
||||||
@@ -1323,7 +1318,7 @@ private fun SplitDayCell(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||||
hidden: List<EventInstance>,
|
hidden: Int,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
inMonth: Boolean,
|
inMonth: Boolean,
|
||||||
@@ -1441,15 +1436,9 @@ private fun SplitDayCell(
|
|||||||
* bar with no dot to grow out of.
|
* bar with no dot to grow out of.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitDots(
|
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
||||||
date: LocalDate,
|
|
||||||
events: List<EventInstance>,
|
|
||||||
hidden: List<EventInstance>,
|
|
||||||
dark: Boolean,
|
|
||||||
) {
|
|
||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val dimCutoff = LocalDimCutoff.current
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1472,21 +1461,18 @@ private fun SplitDots(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden.isNotEmpty()) {
|
if (hidden > 0) {
|
||||||
// Tagged, not lifted: this count and the expanded grid's dot row are
|
// Tagged, not lifted: this count and the expanded grid's dot row are
|
||||||
// the same marker on the same day, so it travels with its cell like
|
// the same marker on the same day, so it travels with its cell like
|
||||||
// everything else rather than riding above the grid on its own layer.
|
// everything else rather than riding above the grid on its own layer.
|
||||||
Text(
|
Text(
|
||||||
text = "+${hidden.size}",
|
text = "+$hidden",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
|
||||||
.morphBounds(MonthMorphKey.Overflow(date))
|
|
||||||
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1884,15 +1870,15 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
val hiddenEvents = buildList {
|
val hiddenColors = buildList {
|
||||||
week.spans
|
week.spans
|
||||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||||
.forEach { add(it.event) }
|
.forEach { add(it.event.color) }
|
||||||
addAll(timed.drop(pillsShown.size))
|
timed.drop(pillsShown.size).forEach { add(it.color) }
|
||||||
}
|
}.distinct().take(3)
|
||||||
OverflowDots(
|
OverflowDots(
|
||||||
events = hiddenEvents,
|
colors = hiddenColors,
|
||||||
total = hidden,
|
extra = hidden - hiddenColors.size,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
@@ -2072,51 +2058,37 @@ private fun MonthBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
|
||||||
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest. A
|
|
||||||
* dot dims once every event sharing its colour has ended, the "+N" once the
|
|
||||||
* whole overflow has (#79).
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowDots(
|
private fun OverflowDots(
|
||||||
events: List<EventInstance>,
|
colors: List<Int>,
|
||||||
total: Int,
|
extra: Int,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val dimCutoff = LocalDimCutoff.current
|
|
||||||
val byColor = events.groupBy { it.color }
|
|
||||||
val dots = byColor.keys.take(3)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
dots.forEach { argb ->
|
colors.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
|
||||||
.background(eventFill(argb, dark, soften), CircleShape),
|
.background(eventFill(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val extra = total - dots.size
|
|
||||||
if (extra > 0) {
|
if (extra > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "+$extra",
|
text = "+$extra",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when dimming is on and every one of [events] is already over. */
|
|
||||||
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
|
|
||||||
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthGridLoading() {
|
private fun MonthGridLoading() {
|
||||||
val shape = MaterialTheme.shapes.medium
|
val shape = MaterialTheme.shapes.medium
|
||||||
|
|||||||
@@ -67,21 +67,6 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
|
||||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
|
||||||
* a count because dimming is a per-event question (#79).
|
|
||||||
*/
|
|
||||||
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
|
||||||
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
|
||||||
return buildList {
|
|
||||||
spans.forEach { span ->
|
|
||||||
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
|
||||||
}
|
|
||||||
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ class PermissionViewModel @Inject constructor() : ViewModel() {
|
|||||||
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
||||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
// The visibility reconcile a grant owes (#75) hangs off RootScreen, which
|
|
||||||
// also catches grants made outside the app.
|
|
||||||
fun onGranted() {
|
fun onGranted() {
|
||||||
_state.value = PermissionUiState.Granted
|
_state.value = PermissionUiState.Granted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -20,7 +19,6 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ReminderOnboardingViewModel @Inject constructor(
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
private val scanner: ReminderScanner,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
@@ -36,18 +34,6 @@ class ReminderOnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setRemindersEnabled(remindersEnabled)
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
prefs.setReminderOnboardingDone()
|
prefs.setReminderOnboardingDone()
|
||||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
|
||||||
// alarm (#75).
|
|
||||||
scanner.scan()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-scan after the calendar permission is granted. The launch scan runs
|
|
||||||
* before the grant and bails out without arming anything, so without this
|
|
||||||
* the first alarm waits on the daily worker.
|
|
||||||
*/
|
|
||||||
fun rearmAfterGrant() {
|
|
||||||
scanner.scanInBackground()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,9 @@ import de.jeanlucmakiola.floret.components.Position
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -223,13 +220,9 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
val start = remember(event.start, zone) {
|
val start = remember(event.start, zone) {
|
||||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||||
}
|
}
|
||||||
// From the shared span rule, not [start]: an all-day event sits at UTC
|
val dateText = remember(locale) {
|
||||||
// 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)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
}.format(start)
|
||||||
}
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val timeText = if (event.isAllDay) {
|
val timeText = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
|||||||
@@ -1,505 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.UploadFile
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appearance: how the app itself looks — theme and colour, the two typeface
|
|
||||||
* roles, and the launcher name. Anything that only changes how a *calendar view*
|
|
||||||
* reads (week start, time format, grid options) lives in [ViewsScreen] instead,
|
|
||||||
* and the widget-only settings in [WidgetsScreen] (#69).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun AppearanceScreen(
|
|
||||||
state: SettingsUiState,
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
|
||||||
var showBrandFont by remember { mutableStateOf(false) }
|
|
||||||
var showPlainFont by remember { mutableStateOf(false) }
|
|
||||||
var showAppName by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
|
||||||
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.fontImportFailed.collect {
|
|
||||||
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
|
||||||
onBack = onBack,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
// Theme & colour
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_theme),
|
|
||||||
summary = themeLabel(state.themeMode),
|
|
||||||
position = Position.Top,
|
|
||||||
onClick = { showTheme = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_dynamic_color),
|
|
||||||
summary = if (state.dynamicColorAvailable) {
|
|
||||||
stringResource(R.string.settings_dynamic_color_summary)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
|
||||||
},
|
|
||||||
position = Position.Middle,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.dynamicColor,
|
|
||||||
onCheckedChange = viewModel::setDynamicColor,
|
|
||||||
enabled = state.dynamicColorAvailable,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = if (state.dynamicColorAvailable) {
|
|
||||||
{ viewModel.setDynamicColor(!state.dynamicColor) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_soften_colors),
|
|
||||||
summary = stringResource(R.string.settings_soften_colors_summary),
|
|
||||||
position = Position.Bottom,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.softenColors,
|
|
||||||
onCheckedChange = viewModel::setSoftenColors,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setSoftenColors(!state.softenColors) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Fonts — the two Material typeface roles (#19).
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_font_headings),
|
|
||||||
summary = fontLabel(fonts.brand),
|
|
||||||
position = Position.Top,
|
|
||||||
onClick = { showBrandFont = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_font_body),
|
|
||||||
summary = fontLabel(fonts.plain),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showPlainFont = true },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// App name — the launcher label (#44); its own group, being a
|
|
||||||
// launcher concern rather than app styling.
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_app_name),
|
|
||||||
summary = launcherNameLabel(launcherName),
|
|
||||||
position = Position.Alone,
|
|
||||||
onClick = { showAppName = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAppName) {
|
|
||||||
FullScreenPicker(
|
|
||||||
title = stringResource(R.string.settings_app_name),
|
|
||||||
onDismiss = { showAppName = false },
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
// Both names shown as launcher-mark previews; tapping applies at
|
|
||||||
// once and the picker stays open so the change is visible.
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_app_name_summary),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
LauncherName.entries.forEach { option ->
|
|
||||||
AppNameOptionCard(
|
|
||||||
name = option,
|
|
||||||
selected = launcherName == option,
|
|
||||||
onClick = { viewModel.setLauncherName(option) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showTheme) {
|
|
||||||
// No thumbnails: picking a theme repaints the app itself. Only which
|
|
||||||
// way "Follow the system" currently falls needs spelling out.
|
|
||||||
val systemDark = isSystemInDarkTheme()
|
|
||||||
OptionPicker(
|
|
||||||
title = stringResource(R.string.settings_theme),
|
|
||||||
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
|
|
||||||
predictiveBack = true,
|
|
||||||
options = ThemeMode.entries,
|
|
||||||
selected = state.themeMode,
|
|
||||||
label = { themeLabel(it) },
|
|
||||||
summary = { mode ->
|
|
||||||
if (mode == ThemeMode.SYSTEM) {
|
|
||||||
stringResource(
|
|
||||||
R.string.settings_theme_system_summary,
|
|
||||||
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelect = viewModel::setThemeMode,
|
|
||||||
onDismiss = { showTheme = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showBrandFont) {
|
|
||||||
FontPicker(
|
|
||||||
title = stringResource(R.string.settings_font_headings),
|
|
||||||
role = FontRole.BRAND,
|
|
||||||
selected = fonts.brand,
|
|
||||||
stamp = fonts.brandStamp,
|
|
||||||
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
|
||||||
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
|
||||||
onDismiss = { showBrandFont = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showPlainFont) {
|
|
||||||
FontPicker(
|
|
||||||
title = stringResource(R.string.settings_font_body),
|
|
||||||
role = FontRole.PLAIN,
|
|
||||||
selected = fonts.plain,
|
|
||||||
stamp = fonts.plainStamp,
|
|
||||||
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
|
||||||
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
|
||||||
onDismiss = { showPlainFont = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
|
||||||
when (mode) {
|
|
||||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
|
||||||
ThemeMode.LIGHT -> R.string.settings_theme_light
|
|
||||||
ThemeMode.DARK -> R.string.settings_theme_dark
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The summary label for a stored font token (issue #19). */
|
|
||||||
@Composable
|
|
||||||
private fun fontLabel(token: String): String = when (token) {
|
|
||||||
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
|
|
||||||
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
|
|
||||||
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
|
|
||||||
?: stringResource(R.string.settings_font_system)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The display name for a launcher-label choice (issue #44). */
|
|
||||||
@Composable
|
|
||||||
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
|
||||||
when (name) {
|
|
||||||
LauncherName.CALENDULA -> R.string.app_name
|
|
||||||
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MIME types offered to the document picker so it lists only font files. Covers
|
|
||||||
* the modern `font/` types plus the legacy `application/` font aliases some
|
|
||||||
* providers still report. Anything that slips through is still validated by
|
|
||||||
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
|
|
||||||
*/
|
|
||||||
private val FONT_PICKER_MIME_TYPES = arrayOf(
|
|
||||||
"font/ttf",
|
|
||||||
"font/otf",
|
|
||||||
"font/sfnt",
|
|
||||||
"font/collection",
|
|
||||||
"application/x-font-ttf",
|
|
||||||
"application/x-font-otf",
|
|
||||||
"application/font-sfnt",
|
|
||||||
"application/vnd.ms-opentype",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen font chooser for one [FontRole]: the system default, each bundled
|
|
||||||
* font, and "Choose file…" for a .ttf/.otf. A specimen of the current choice
|
|
||||||
* sits above the rows, so the picker stays open on selection and re-renders it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun FontPicker(
|
|
||||||
title: String,
|
|
||||||
role: FontRole,
|
|
||||||
selected: String,
|
|
||||||
stamp: Int,
|
|
||||||
onSelect: (String) -> Unit,
|
|
||||||
onImport: (Uri) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) onImport(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
// System default + the bundled fonts + the "Choose file…" row.
|
|
||||||
val rowCount = BundledFont.entries.size + 2
|
|
||||||
val isCustom = selected == FONT_CUSTOM_TOKEN
|
|
||||||
// Resolving the custom face stats the disk, so memoise it; [stamp] is bumped
|
|
||||||
// on re-import to refresh a replaced file.
|
|
||||||
val customPreview = remember(role, isCustom, stamp) {
|
|
||||||
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
|
|
||||||
}
|
|
||||||
val selectedFamily = remember(role, selected, stamp) {
|
|
||||||
resolveFontFamily(selected, role, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
|
||||||
FontSpecimen(role = role, family = selectedFamily)
|
|
||||||
FontOptionRow(
|
|
||||||
label = stringResource(R.string.settings_font_system),
|
|
||||||
preview = FontFamily.Default,
|
|
||||||
selected = selected == FONT_SYSTEM_TOKEN,
|
|
||||||
position = positionOf(0, rowCount),
|
|
||||||
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
|
|
||||||
)
|
|
||||||
BundledFont.entries.forEachIndexed { index, font ->
|
|
||||||
FontOptionRow(
|
|
||||||
label = stringResource(font.labelRes),
|
|
||||||
preview = font.family,
|
|
||||||
selected = selected == font.token,
|
|
||||||
position = positionOf(index + 1, rowCount),
|
|
||||||
onClick = { onSelect(font.token) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
FontOptionRow(
|
|
||||||
label = if (isCustom) {
|
|
||||||
stringResource(R.string.settings_font_custom_selected)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.settings_font_choose_file)
|
|
||||||
},
|
|
||||||
preview = customPreview,
|
|
||||||
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
|
||||||
selected = isCustom,
|
|
||||||
position = positionOf(rowCount - 1, rowCount),
|
|
||||||
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A specimen of [family] in the type role [role] governs, using the app's own
|
|
||||||
* styles with only the family swapped. A null [family] is the system typeface.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
|
|
||||||
val isBrand = role == FontRole.BRAND
|
|
||||||
val style = if (isBrand) {
|
|
||||||
MaterialTheme.typography.headlineMedium
|
|
||||||
} else {
|
|
||||||
MaterialTheme.typography.bodyLarge
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body,
|
|
||||||
),
|
|
||||||
style = style.copy(fontFamily = family ?: style.fontFamily),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
// Fixed height, so switching between a wide and a narrow face doesn't
|
|
||||||
// shuffle the option list under it.
|
|
||||||
minLines = 2,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row in the [FontPicker]: the font's name, an "Ag" sample in its own
|
|
||||||
* [preview] face (or an [leadingIcon] cue), and a check when selected.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun FontOptionRow(
|
|
||||||
label: String,
|
|
||||||
preview: FontFamily?,
|
|
||||||
selected: Boolean,
|
|
||||||
position: Position,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
leadingIcon: ImageVector? = null,
|
|
||||||
) {
|
|
||||||
GroupedRow(
|
|
||||||
title = label,
|
|
||||||
position = position,
|
|
||||||
selected = selected,
|
|
||||||
leading = {
|
|
||||||
if (preview != null) {
|
|
||||||
Text(
|
|
||||||
text = "Ag",
|
|
||||||
fontFamily = preview,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
} else if (leadingIcon != null) {
|
|
||||||
Icon(imageVector = leadingIcon, contentDescription = null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailing = if (selected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One selectable launcher-name preview in the App name picker (#44): the app's
|
|
||||||
* launcher mark over the name, framed as a card.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun AppNameOptionCard(
|
|
||||||
name: LauncherName,
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val shape = RoundedCornerShape(24.dp)
|
|
||||||
val borderColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.outlineVariant
|
|
||||||
}
|
|
||||||
val containerColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.clip(shape)
|
|
||||||
.background(containerColor)
|
|
||||||
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(vertical = 20.dp, horizontal = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
// The adaptive mark rebuilt as a squircle, as in the onboarding
|
|
||||||
// BrandHero, so it renders identically everywhere.
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.clip(RoundedCornerShape(18.dp))
|
|
||||||
.background(colorResource(R.color.ic_launcher_background)),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = launcherNameLabel(name),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
|
||||||
.then(
|
|
||||||
if (selected) {
|
|
||||||
Modifier
|
|
||||||
} else {
|
|
||||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (selected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
|
|
||||||
/** New event form: which fields it opens with, and how it behaves. */
|
|
||||||
@Composable
|
|
||||||
internal fun EventFormScreen(
|
|
||||||
state: SettingsUiState,
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
|
||||||
onBack = onBack,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
val fields = EventFormField.entries
|
|
||||||
fields.forEachIndexed { index, field ->
|
|
||||||
val checked = field in state.defaultFormFields
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(eventFormFieldLabel(field)),
|
|
||||||
position = positionOf(index, fields.size),
|
|
||||||
// The same icon the field carries in the new-event form.
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = eventFormFieldIcon(field),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = checked,
|
|
||||||
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-focus the title on a new event (#10), on by default.
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_autofocus_title),
|
|
||||||
summary = stringResource(R.string.settings_autofocus_title_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Keyboard,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.autofocusEventTitle,
|
|
||||||
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Per-event colour on calendars that publish no colour set (some
|
|
||||||
// CalDAV); off by default, since it may not survive their next sync.
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_color_unsupported),
|
|
||||||
summary = stringResource(R.string.settings_color_unsupported_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.allowColorOnUnsupportedCalendars,
|
|
||||||
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
viewModel.setAllowColorOnUnsupportedCalendars(
|
|
||||||
!state.allowColorOnUnsupportedCalendars,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
|
||||||
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
|
||||||
* the pref is set either way; the OS permission is the real gate.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun NotificationsScreen(
|
|
||||||
state: SettingsUiState,
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onOpenSpecialDates: () -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
|
||||||
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
|
||||||
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
|
||||||
viewModel.setRemindersEnabled(enabled)
|
|
||||||
val needsPermission = enabled &&
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context, Manifest.permission.POST_NOTIFICATIONS,
|
|
||||||
) != PackageManager.PERMISSION_GRANTED
|
|
||||||
if (needsPermission) {
|
|
||||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showDefaultReminder by remember { mutableStateOf(false) }
|
|
||||||
var showAllDayReminder by remember { mutableStateOf(false) }
|
|
||||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
|
||||||
var showSnooze by remember { mutableStateOf(false) }
|
|
||||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
|
||||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
|
||||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
|
||||||
onBack = onBack,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_reminders),
|
|
||||||
summary = stringResource(R.string.settings_reminders_hint),
|
|
||||||
position = Position.Top,
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
|
||||||
},
|
|
||||||
onClick = { toggleReminders(!state.remindersEnabled) },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
|
||||||
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { showDefaultReminder = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_reminder_allday),
|
|
||||||
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { showAllDayReminder = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_allday_reminder_time),
|
|
||||||
summary = stringResource(
|
|
||||||
R.string.settings_allday_reminder_time_hint,
|
|
||||||
settingsTimeOfDay(state.allDayReminderTimeMinutes),
|
|
||||||
),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showAllDayReminderTime = true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reliability is a soft, optional battery-optimisation exemption via
|
|
||||||
// a system-settings deep-link, shown as live status.
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
val batteryExempt = rememberBatteryOptimizationExempt()
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_reliable_delivery),
|
|
||||||
summary = if (batteryExempt) {
|
|
||||||
stringResource(R.string.settings_reliable_delivery_exempt)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.settings_reliable_delivery_hint)
|
|
||||||
},
|
|
||||||
position = Position.Top,
|
|
||||||
trailing = if (batteryExempt) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { openBatteryOptimizationSettings(context) },
|
|
||||||
)
|
|
||||||
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
|
||||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showSnooze = true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Per-calendar overrides, folded behind one header. Each writable
|
|
||||||
// calendar may keep, drop or replace the global default, separately for
|
|
||||||
// timed and all-day events.
|
|
||||||
if (state.writableCalendars.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
|
||||||
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
trailing = {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = calendarSectionExpanded,
|
|
||||||
enter = expandEnter(),
|
|
||||||
exit = collapseExit(),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
state.writableCalendars.forEach { calendar ->
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
// Special-dates calendars own their reminders in their
|
|
||||||
// own section — link there instead.
|
|
||||||
if (calendar.id in state.managedCalendarIds) {
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
|
||||||
trailing = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onOpenSpecialDates,
|
|
||||||
)
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
val expanded = calendar.id in expandedCalendars
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
position = if (expanded) Position.Top else Position.Alone,
|
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
|
||||||
trailing = {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
expandedCalendars = if (expanded) {
|
|
||||||
expandedCalendars - calendar.id
|
|
||||||
} else {
|
|
||||||
expandedCalendars + calendar.id
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = expanded,
|
|
||||||
enter = expandEnter(),
|
|
||||||
exit = collapseExit(),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
|
||||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
|
||||||
)
|
|
||||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_reminder_allday),
|
|
||||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSnooze) {
|
|
||||||
SnoozeDurationPicker(
|
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
|
||||||
presets = SNOOZE_PRESETS,
|
|
||||||
selected = state.snoozeMinutes,
|
|
||||||
label = { snoozeDurationLabel(it) },
|
|
||||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
|
||||||
onDismiss = { showSnooze = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showDefaultReminder) {
|
|
||||||
ReminderDefaultPicker(
|
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
|
||||||
presets = REMINDER_PRESETS,
|
|
||||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
|
||||||
allowInherit = false,
|
|
||||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
|
||||||
onDismiss = { showDefaultReminder = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showAllDayReminder) {
|
|
||||||
ReminderDefaultPicker(
|
|
||||||
title = stringResource(R.string.settings_default_reminder_allday),
|
|
||||||
presets = ALLDAY_REMINDER_PRESETS,
|
|
||||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
|
||||||
allowInherit = false,
|
|
||||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
|
||||||
onDismiss = { showAllDayReminder = false },
|
|
||||||
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showAllDayReminderTime) {
|
|
||||||
TimePickerAlert(
|
|
||||||
initial = LocalTime(
|
|
||||||
state.allDayReminderTimeMinutes / 60,
|
|
||||||
state.allDayReminderTimeMinutes % 60,
|
|
||||||
),
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
|
|
||||||
showAllDayReminderTime = false
|
|
||||||
},
|
|
||||||
onDismiss = { showAllDayReminderTime = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
overrideDialog?.let { target ->
|
|
||||||
val map = if (target.isAllDay) {
|
|
||||||
state.perCalendarAllDayReminderOverride
|
|
||||||
} else {
|
|
||||||
state.perCalendarReminderOverride
|
|
||||||
}
|
|
||||||
ReminderDefaultPicker(
|
|
||||||
title = stringResource(
|
|
||||||
if (target.isAllDay) {
|
|
||||||
R.string.settings_default_reminder_allday
|
|
||||||
} else {
|
|
||||||
R.string.settings_default_reminder
|
|
||||||
},
|
|
||||||
),
|
|
||||||
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
|
||||||
selected = map.reminderOverrideFor(target.calendarId),
|
|
||||||
allowInherit = true,
|
|
||||||
onSelect = {
|
|
||||||
if (target.isAllDay) {
|
|
||||||
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
|
|
||||||
} else {
|
|
||||||
viewModel.setCalendarReminderOverride(target.calendarId, it)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = { overrideDialog = null },
|
|
||||||
leadTimeSummary = if (target.isAllDay) {
|
|
||||||
allDayFiringTimeSummary(state.allDayReminderTimeMinutes)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
|
||||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
|
||||||
|
|
||||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
|
||||||
private fun List<Int>.toReminderChoice(): ReminderOverride =
|
|
||||||
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
|
|
||||||
|
|
||||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
|
||||||
private fun ReminderOverride.toMinutesList(): List<Int> =
|
|
||||||
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
|
|
||||||
|
|
||||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
|
||||||
@Composable
|
|
||||||
private fun calendarOverrideSummary(
|
|
||||||
choice: ReminderOverride,
|
|
||||||
globalDefault: List<Int>,
|
|
||||||
): String = when (choice) {
|
|
||||||
ReminderOverride.Inherit ->
|
|
||||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
|
||||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
|
||||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
|
||||||
|
|
||||||
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
|
||||||
@Composable
|
|
||||||
private fun snoozeDurationLabel(minutes: Int): String =
|
|
||||||
if (minutes % 60 == 0) {
|
|
||||||
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
|
||||||
} else {
|
|
||||||
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
|
||||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun rememberBatteryOptimizationExempt(): Boolean {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
|
||||||
if (event == Lifecycle.Event.ON_RESUME) {
|
|
||||||
exempt = isIgnoringBatteryOptimizations(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
|
||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
|
||||||
}
|
|
||||||
return exempt
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
|
||||||
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
return power.isIgnoringBatteryOptimizations(context.packageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the direct `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog, falling back to
|
|
||||||
* the optimisation list if the OS refuses it.
|
|
||||||
*/
|
|
||||||
private fun openBatteryOptimizationSettings(context: Context) {
|
|
||||||
val direct = Intent(
|
|
||||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
|
||||||
"package:${context.packageName}".toUri(),
|
|
||||||
)
|
|
||||||
if (runCatching { context.startActivity(direct) }.isFailure) {
|
|
||||||
runCatching {
|
|
||||||
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.snap
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
|
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "past events" chooser for the Agenda (#69 follow-up). A stand-in agenda
|
|
||||||
* day sits above the options and re-renders as you pick one, using the Agenda's
|
|
||||||
* own rows and filtering. A preview picker, so it stays open on selection.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun PastEventsPicker(
|
|
||||||
selected: PastEventDisplay,
|
|
||||||
onSelect: (PastEventDisplay) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val options = PastEventDisplay.entries
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
FullScreenPicker(
|
|
||||||
title = stringResource(R.string.settings_past_events),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
.height(PREVIEW_HEIGHT),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
|
||||||
) {
|
|
||||||
Crossfade(
|
|
||||||
targetState = selected,
|
|
||||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
|
||||||
label = "past-events-preview",
|
|
||||||
) { shown ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(PREVIEW_SHAPE)
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
PastEventsPreview(mode = shown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PickerDescription(stringResource(R.string.settings_past_events_hint))
|
|
||||||
options.forEachIndexed { index, mode ->
|
|
||||||
val isSelected = mode == selected
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(pastEventDisplayLabelRes(mode)),
|
|
||||||
position = positionOf(index, options.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { onSelect(mode) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stand-in agenda day in a given [mode]: three events on today, the first two
|
|
||||||
* finished. Times are fixed rather than clock-derived, so it reads the same at
|
|
||||||
* any hour.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
|
||||||
val titles = listOf(
|
|
||||||
stringResource(R.string.settings_past_events_sample_morning),
|
|
||||||
stringResource(R.string.settings_past_events_sample_midday),
|
|
||||||
stringResource(R.string.settings_past_events_sample_evening),
|
|
||||||
)
|
|
||||||
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
|
|
||||||
// The same three-way split AgendaContent makes.
|
|
||||||
val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clipToBounds()
|
|
||||||
// A picture, not a control: take the taps the fake rows would
|
|
||||||
// otherwise handle, and keep it out of the reading order. Taps only —
|
|
||||||
// consuming drags would stop the picker scrolling from here.
|
|
||||||
.pointerInput(Unit) { detectTapGestures { } }
|
|
||||||
.clearAndSetSemantics { },
|
|
||||||
) {
|
|
||||||
AgendaDayHeader(date = today, today = today, onOpenDay = {})
|
|
||||||
visible.forEachIndexed { index, row ->
|
|
||||||
AgendaEventRow(
|
|
||||||
event = row.event,
|
|
||||||
day = today,
|
|
||||||
zone = zone,
|
|
||||||
position = positionOf(index, visible.size),
|
|
||||||
dimmed = mode == PastEventDisplay.DIM && row.hasPassed,
|
|
||||||
onClick = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One stand-in row plus whether it counts as finished. */
|
|
||||||
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Today's stand-in events, [titles] in order: two finished and one to come.
|
|
||||||
* Colours are raw ARGB, as the provider hands them out.
|
|
||||||
*/
|
|
||||||
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
|
|
||||||
listOf(
|
|
||||||
sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true),
|
|
||||||
sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true),
|
|
||||||
sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun sampleRow(
|
|
||||||
id: Long,
|
|
||||||
title: String,
|
|
||||||
day: LocalDate,
|
|
||||||
zone: TimeZone,
|
|
||||||
startHour: Int,
|
|
||||||
endHour: Int,
|
|
||||||
color: Int,
|
|
||||||
hasPassed: Boolean,
|
|
||||||
): SampleRow = SampleRow(
|
|
||||||
event = EventInstance(
|
|
||||||
instanceId = id,
|
|
||||||
eventId = id,
|
|
||||||
calendarId = 1L,
|
|
||||||
title = title,
|
|
||||||
start = day.atTime(startHour, 0).toInstant(zone),
|
|
||||||
end = day.atTime(endHour, 0).toInstant(zone),
|
|
||||||
isAllDay = false,
|
|
||||||
color = color,
|
|
||||||
location = null,
|
|
||||||
),
|
|
||||||
hasPassed = hasPassed,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Room for the day header plus its three two-line rows, so hiding shortens the
|
|
||||||
* list inside a box that keeps its height instead of the page jumping a row. */
|
|
||||||
private val PREVIEW_HEIGHT = 288.dp
|
|
||||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each
|
|
||||||
* sub-screen owns whatever only it uses; anything two of them need lives here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accent for a leading icon chip. The chips are a scanning aid, so no two rows
|
|
||||||
* in one group share an accent; [Neutral] is a step back for reference rows
|
|
||||||
* rather than a fourth colour to rotate through.
|
|
||||||
*/
|
|
||||||
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
|
|
||||||
|
|
||||||
/** Leading circular icon chip, coloured by an M3 container/on-container pair. */
|
|
||||||
@Composable
|
|
||||||
internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val (background, iconColor) = when (accent) {
|
|
||||||
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
|
|
||||||
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
|
|
||||||
ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
|
||||||
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(background),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = iconColor,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A small primary-coloured group label, matching the Calendars settings screen.
|
|
||||||
* Inset to [GroupedListInset] so it shares the cards' left margin.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun SectionHeader(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 4.dp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
|
||||||
@Composable
|
|
||||||
internal fun SettingsHint(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun openUrl(context: Context, url: String) {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
|
||||||
runCatching { context.startActivity(intent) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
|
||||||
@Composable
|
|
||||||
internal fun reminderChoiceLabel(minutes: List<Int>): String {
|
|
||||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
|
||||||
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Day-scale lead times for all-day defaults and contact special dates. */
|
|
||||||
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
|
||||||
|
|
||||||
/** A minute-of-day in the app's own 12/24-hour convention ([LocalUse24HourFormat]). */
|
|
||||||
@Composable
|
|
||||||
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
|
|
||||||
formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Second line for an all-day lead-time row: the clock time it fires at. The
|
|
||||||
* offset only decides which day; the hour comes from the global all-day setting
|
|
||||||
* (see [de.jeanlucmakiola.calendula.domain.reminders.planReminders]).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {
|
|
||||||
val summary = stringResource(
|
|
||||||
R.string.settings_allday_reminder_fires_at,
|
|
||||||
settingsTimeOfDay(allDayReminderTimeMinutes),
|
|
||||||
)
|
|
||||||
return { summary }
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -49,8 +48,6 @@ data class SettingsUiState(
|
|||||||
val agendaShowToday: Boolean = true,
|
val agendaShowToday: Boolean = true,
|
||||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The size step the agenda widget draws its text at (#51). */
|
|
||||||
val widgetSize: WidgetSize = WidgetSize.SMALL,
|
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -39,11 +38,9 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
|||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -71,20 +68,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
private val launcherNameManager: LauncherNameManager,
|
private val launcherNameManager: LauncherNameManager,
|
||||||
private val reminderScanner: ReminderScanner,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
/**
|
/** Writable calendars — the only ones that take a per-calendar reminder override. */
|
||||||
* Writable calendars that are switched on — the only ones that take a
|
|
||||||
* per-calendar reminder override, since a switched-off one plans no
|
|
||||||
* reminders at all (#75).
|
|
||||||
*/
|
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
.catch { emit(emptyList()) }
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
@@ -163,9 +155,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.quickSwitchConfig,
|
prefs.quickSwitchConfig,
|
||||||
prefs.drawerViewOrder,
|
prefs.drawerViewOrder,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.widgetSize,
|
) { quickSwitch, drawer, monthStyle ->
|
||||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
ViewCustomization(quickSwitch, drawer, monthStyle)
|
||||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -188,7 +179,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
widgetSize = misc.viewCustomization.widgetSize,
|
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -224,10 +214,17 @@ class SettingsViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The launcher-label choice (#44), backed by the manifest aliases'
|
* The launcher-label choice (issue #44). Backed by the manifest aliases'
|
||||||
* component-enabled state rather than a preference. Seeded with the manifest
|
* component-enabled state rather than a stored preference, so it's read
|
||||||
* default and corrected off-thread — `getComponentEnabledSetting` is a
|
* imperatively via [LauncherNameManager] and held here in its own flow — the
|
||||||
* binder round-trip and this ViewModel is built on the main thread.
|
* main settings combine is already at its arity limit and this isn't a
|
||||||
|
* DataStore flow anyway.
|
||||||
|
*
|
||||||
|
* Seeded with the manifest default and corrected off-thread rather than read
|
||||||
|
* in the initializer: `getComponentEnabledSetting` is a binder round-trip to
|
||||||
|
* system_server, and this ViewModel is constructed on the main thread while
|
||||||
|
* Settings is opening. The row it feeds is well below the fold, so the
|
||||||
|
* one-frame correction is never visible.
|
||||||
*/
|
*/
|
||||||
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
|
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
|
||||||
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
|
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
|
||||||
@@ -243,8 +240,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||||
|
|
||||||
// Serialises widget redraws: concurrent updateAll calls coalesce around a
|
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||||
// stale read and can leave the widget on an intermediate value.
|
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||||
|
// and can leave the widget on the intermediate value. Held sequentially,
|
||||||
|
// the last call reads the final committed pref and renders it.
|
||||||
private val widgetRefreshMutex = Mutex()
|
private val widgetRefreshMutex = Mutex()
|
||||||
|
|
||||||
private data class ReminderDefaults(
|
private data class ReminderDefaults(
|
||||||
@@ -294,7 +293,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val quickSwitch: QuickSwitchConfig,
|
val quickSwitch: QuickSwitchConfig,
|
||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val widgetSize: WidgetSize,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
@@ -397,7 +395,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setSoftenColors(enabled: Boolean) {
|
fun setSoftenColors(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setSoftenCalendarColors(enabled)
|
prefs.setSoftenCalendarColors(enabled)
|
||||||
// Both widgets paint event colours through the same softener.
|
// Both widgets paint event colours through the same softener, so a
|
||||||
|
// change has to redraw them too (they read the flag in their data
|
||||||
|
// preamble, like is24Hour).
|
||||||
widgetRefreshMutex.withLock {
|
widgetRefreshMutex.withLock {
|
||||||
AgendaWidget().updateAll(appContext)
|
AgendaWidget().updateAll(appContext)
|
||||||
MonthWidget().updateAll(appContext)
|
MonthWidget().updateAll(appContext)
|
||||||
@@ -423,8 +423,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// Bump the stamp so replacing an already-active custom font
|
// Bump the stamp so replacing an already-active custom font (same
|
||||||
// still breaks equality and refreshes the resolved typeface.
|
// file, same "custom" token) still breaks font-settings equality
|
||||||
|
// and refreshes the resolved typeface.
|
||||||
prefs.bumpCustomFontStamp(role)
|
prefs.bumpCustomFontStamp(role)
|
||||||
setFont(role, FONT_CUSTOM_TOKEN)
|
setFont(role, FONT_CUSTOM_TOKEN)
|
||||||
} else {
|
} else {
|
||||||
@@ -453,8 +454,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setAgendaWidgetRange(range)
|
prefs.setAgendaWidgetRange(range)
|
||||||
widgetRefreshMutex.withLock {
|
widgetRefreshMutex.withLock {
|
||||||
// Push into each instance's Glance state and recompose:
|
// Push the range into each instance's Glance state and recompose.
|
||||||
// updateAll alone won't re-run provideGlance's data preamble.
|
// The widget reads it via currentState, so this reflects reliably
|
||||||
|
// even when updateAll only recomposes a live session (it does not
|
||||||
|
// re-run provideGlance's data preamble).
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||||
@@ -468,28 +471,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the size the agenda widget draws its text at (#51), via the same
|
|
||||||
* Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
|
|
||||||
* sizes itself from the space it is given (#103).
|
|
||||||
*/
|
|
||||||
fun setWidgetSize(size: WidgetSize) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setWidgetSize(size)
|
|
||||||
widgetRefreshMutex.withLock {
|
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
|
||||||
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
|
|
||||||
}
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAgendaShowToday(enabled: Boolean) {
|
fun setAgendaShowToday(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setAgendaShowToday(enabled)
|
prefs.setAgendaShowToday(enabled)
|
||||||
// Same Glance-state path as setPastEventDisplay.
|
// The agenda widget reads this reactively, so push it into each
|
||||||
|
// instance's Glance state and recompose — same reliable-update path as
|
||||||
|
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
|
||||||
widgetRefreshMutex.withLock {
|
widgetRefreshMutex.withLock {
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
@@ -517,9 +504,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
* Switch the launcher label between "Calendula" and "Calendar" (issue #44).
|
||||||
* on what the component state reports; the writes are binder round-trips and
|
* The card highlights immediately, then settles on whatever the component
|
||||||
* run off the main thread.
|
* state actually reports — the two `setComponentEnabledSetting` calls are
|
||||||
|
* binder round-trips, so they don't belong on the main thread either.
|
||||||
*/
|
*/
|
||||||
fun setLauncherName(name: LauncherName) {
|
fun setLauncherName(name: LauncherName) {
|
||||||
_launcherName.value = name
|
_launcherName.value = name
|
||||||
@@ -534,7 +522,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setPastEventDisplay(mode)
|
prefs.setPastEventDisplay(mode)
|
||||||
// Same Glance-state path as setAgendaWidgetRange.
|
// The agenda widget honours this setting too, so push it into each
|
||||||
|
// instance's Glance state and recompose — same reliable-update path as
|
||||||
|
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
|
||||||
widgetRefreshMutex.withLock {
|
widgetRefreshMutex.withLock {
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
@@ -558,10 +548,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
|
||||||
* read-modify-write so a concurrent reorder can't clobber it. The
|
* read-modify-write so a concurrent reorder can't clobber this toggle (and
|
||||||
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
|
||||||
* own guard reads an async-echoed snapshot.
|
* re-checked inside the transform: the screen's own guard reads the
|
||||||
|
* async-echoed snapshot, so two quick disable-taps could both look allowed
|
||||||
|
* yet compose to a below-minimum cycle.
|
||||||
*/
|
*/
|
||||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -585,15 +577,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A scan follows the write: switching reminders off cancels the scan alarm,
|
|
||||||
* so switching them back on has to arm a new one (#75).
|
|
||||||
*/
|
|
||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
prefs.setRemindersEnabled(enabled)
|
|
||||||
reminderScanner.scan()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||||
@@ -608,12 +593,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
|
|
||||||
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
prefs.setAllDayReminderTimeMinutes(minutesOfDay)
|
|
||||||
reminderScanner.scan()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSnoozeMinutes(minutes: Int) {
|
fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Contact special dates (issue #15)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun SpecialDatesScreen(
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
|
||||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// READ_CONTACTS is requested only here, on enable — never at startup.
|
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val requestOrEnable: () -> Unit = {
|
|
||||||
if (context.hasContactsPermission()) {
|
|
||||||
viewModel.setSpecialDatesEnabled(true)
|
|
||||||
} else {
|
|
||||||
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var confirmDisableAll by remember { mutableStateOf(false) }
|
|
||||||
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
|
|
||||||
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
|
|
||||||
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.settings_section_special_dates),
|
|
||||||
onBack = onBack,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
// Paused banner: the permission was revoked after enabling.
|
|
||||||
if (state.enabled && state.stalledPermission) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_special_dates_paused_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_special_dates_paused_hint),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
|
|
||||||
) { Text(stringResource(R.string.settings_special_dates_grant)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_special_dates_enable),
|
|
||||||
summary = stringResource(R.string.settings_special_dates_enable_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.enabled,
|
|
||||||
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (state.enabled) {
|
|
||||||
SpecialDateType.entries.forEachIndexed { index, type ->
|
|
||||||
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
|
|
||||||
val on = type in state.types
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(specialDateTypeLabel(type)),
|
|
||||||
position = if (on) Position.Top else Position.Alone,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = on,
|
|
||||||
onCheckedChange = { want ->
|
|
||||||
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
|
|
||||||
else confirmDisableType = type
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (on) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_special_dates_template),
|
|
||||||
summary = state.titleTemplates[type].orEmpty(),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { editTemplate = type },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_special_dates_reminders),
|
|
||||||
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { reminderPickerType = type },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_special_dates_show_year),
|
|
||||||
summary = stringResource(R.string.settings_special_dates_show_year_hint),
|
|
||||||
position = Position.Top,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.showYear,
|
|
||||||
onCheckedChange = viewModel::setSpecialDatesShowYear,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_special_dates_sync_now),
|
|
||||||
summary = specialDatesLastRunLabel(context, state.lastRun),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = viewModel::syncSpecialDatesNow,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_special_dates_calendar_hint),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirmDisableAll) {
|
|
||||||
SpecialDatesDisableDialog(
|
|
||||||
message = stringResource(R.string.settings_special_dates_disable_all_message),
|
|
||||||
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
|
|
||||||
onDismiss = { confirmDisableAll = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
confirmDisableType?.let { type ->
|
|
||||||
SpecialDatesDisableDialog(
|
|
||||||
message = stringResource(
|
|
||||||
R.string.settings_special_dates_disable_type_message,
|
|
||||||
stringResource(specialDateTypeLabel(type)),
|
|
||||||
),
|
|
||||||
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
|
|
||||||
onDismiss = { confirmDisableType = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
editTemplate?.let { type ->
|
|
||||||
SpecialDatesTemplateDialog(
|
|
||||||
initial = state.titleTemplates[type].orEmpty(),
|
|
||||||
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
|
|
||||||
onDismiss = { editTemplate = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
reminderPickerType?.let { type ->
|
|
||||||
ReminderDefaultPicker(
|
|
||||||
title = stringResource(R.string.settings_special_dates_reminders),
|
|
||||||
presets = ALLDAY_REMINDER_PRESETS,
|
|
||||||
selected = state.reminderChoices[type] ?: ReminderOverride.None,
|
|
||||||
// Managed calendars own their reminders outright — no "inherit global".
|
|
||||||
allowInherit = false,
|
|
||||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
|
||||||
onDismiss = { reminderPickerType = null },
|
|
||||||
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
|
||||||
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
|
|
||||||
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SpecialDatesDisableDialog(
|
|
||||||
message: String,
|
|
||||||
onConfirm: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
|
||||||
text = { Text(message) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onConfirm) {
|
|
||||||
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SpecialDatesTemplateDialog(
|
|
||||||
initial: String,
|
|
||||||
onConfirm: (String) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
var text by rememberSaveable { mutableStateOf(initial) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
// The dialog convention — see DialogControls.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
InlineTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
placeholder = stringResource(R.string.settings_special_dates_template),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_special_dates_template_hint),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { onConfirm(text) },
|
|
||||||
enabled = text.isNotBlank(),
|
|
||||||
) { Text(stringResource(R.string.dialog_save)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
|
||||||
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
|
|
||||||
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
|
|
||||||
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
|
||||||
if (lastRun <= 0L) {
|
|
||||||
stringResource(R.string.settings_special_dates_never_synced)
|
|
||||||
} else {
|
|
||||||
stringResource(
|
|
||||||
R.string.settings_special_dates_last_synced,
|
|
||||||
android.text.format.DateUtils.getRelativeTimeSpanString(
|
|
||||||
lastRun,
|
|
||||||
System.currentTimeMillis(),
|
|
||||||
android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
|
||||||
).toString(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.text.format.DateFormat
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
|
||||||
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Views: everything that changes how a calendar view reads, grouped by the view
|
|
||||||
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
|
||||||
*
|
|
||||||
* The quick-switch cycle and the drawer list are independent orders — a view
|
|
||||||
* off in the cycle is still reachable from the drawer. The cycle needs at least
|
|
||||||
* [QuickSwitchConfig.MIN_ENABLED] targets.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun ViewsScreen(
|
|
||||||
state: SettingsUiState,
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
var showMonthStyle by remember { mutableStateOf(false) }
|
|
||||||
var showDefaultView by remember { mutableStateOf(false) }
|
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
|
||||||
var showTimeFormat by remember { mutableStateOf(false) }
|
|
||||||
var showPastEvents by remember { mutableStateOf(false) }
|
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.settings_section_views),
|
|
||||||
onBack = onBack,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
val config = state.quickSwitchConfig
|
|
||||||
|
|
||||||
// What holds for every view, above the per-view groups.
|
|
||||||
SectionHeader(stringResource(R.string.settings_views_all_header))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_view),
|
|
||||||
summary = stringResource(state.defaultView.labelRes),
|
|
||||||
position = Position.Top,
|
|
||||||
onClick = { showDefaultView = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_week_start),
|
|
||||||
summary = weekStartLabel(state.weekStart),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { showWeekStart = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_time_format),
|
|
||||||
summary = timeFormatLabel(state.timeFormat),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { showTimeFormat = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_today_toolbar),
|
|
||||||
summary = stringResource(R.string.settings_today_toolbar_summary),
|
|
||||||
position = Position.Middle,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.todayButtonInToolbar,
|
|
||||||
onCheckedChange = viewModel::setTodayButtonInToolbar,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_dim_completed),
|
|
||||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
|
||||||
position = Position.Bottom,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.dimCompletedEvents,
|
|
||||||
onCheckedChange = viewModel::setDimCompletedEvents,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
SectionHeader(stringResource(R.string.settings_month_header))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_month_view_style),
|
|
||||||
summary = stringResource(state.monthViewStyle.labelRes),
|
|
||||||
position = Position.Top,
|
|
||||||
onClick = { showMonthStyle = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_week_numbers),
|
|
||||||
summary = stringResource(R.string.settings_week_numbers_summary),
|
|
||||||
position = Position.Bottom,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.showWeekNumbers,
|
|
||||||
onCheckedChange = viewModel::setShowWeekNumbers,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_hour_lines),
|
|
||||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
|
||||||
position = Position.Alone,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.showHourLines,
|
|
||||||
onCheckedChange = viewModel::setShowHourLines,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
SectionHeader(stringResource(R.string.settings_agenda_header))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
|
||||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
|
||||||
position = Position.Top,
|
|
||||||
onClick = { showAgendaScreenRange = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_past_events),
|
|
||||||
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { showPastEvents = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_agenda_show_today),
|
|
||||||
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
|
||||||
position = Position.Middle,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.agendaShowToday,
|
|
||||||
onCheckedChange = viewModel::setAgendaShowToday,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_agenda_range_bar),
|
|
||||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
|
||||||
position = Position.Bottom,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = state.agendaShowRangeBar,
|
|
||||||
onCheckedChange = viewModel::setAgendaShowRangeBar,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// Turning a view off is blocked once only the minimum remain enabled.
|
|
||||||
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
|
||||||
ReorderableColumn(
|
|
||||||
items = config.order,
|
|
||||||
keyOf = { it },
|
|
||||||
onReorder = { viewModel.setQuickSwitchOrder(it) },
|
|
||||||
) { view, position, dragHandle, isDragging ->
|
|
||||||
val checked = view in config.enabled
|
|
||||||
ViewRow(
|
|
||||||
view = view,
|
|
||||||
position = position,
|
|
||||||
isDragging = isDragging,
|
|
||||||
dragHandle = dragHandle,
|
|
||||||
dimmed = !checked,
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = checked,
|
|
||||||
// Keep the last two on: with fewer, the pill can't switch.
|
|
||||||
enabled = !checked || canDisable,
|
|
||||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
SectionHeader(stringResource(R.string.settings_drawer_order_header))
|
|
||||||
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
ReorderableColumn(
|
|
||||||
items = state.drawerViewOrder,
|
|
||||||
keyOf = { it },
|
|
||||||
onReorder = { viewModel.setDrawerViewOrder(it) },
|
|
||||||
) { view, position, dragHandle, isDragging ->
|
|
||||||
ViewRow(
|
|
||||||
view = view,
|
|
||||||
position = position,
|
|
||||||
isDragging = isDragging,
|
|
||||||
dragHandle = dragHandle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showMonthStyle) {
|
|
||||||
MonthViewStylePicker(
|
|
||||||
selected = state.monthViewStyle,
|
|
||||||
// The preview is a real grid, so it uses the real week start too.
|
|
||||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
|
||||||
onSelect = viewModel::setMonthViewStyle,
|
|
||||||
onDismiss = { showMonthStyle = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showDefaultView) {
|
|
||||||
OptionPicker(
|
|
||||||
title = stringResource(R.string.settings_default_view),
|
|
||||||
header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) },
|
|
||||||
predictiveBack = true,
|
|
||||||
options = IMPLEMENTED_VIEWS,
|
|
||||||
selected = state.defaultView,
|
|
||||||
label = { stringResource(it.labelRes) },
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = it.icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onSelect = viewModel::setDefaultView,
|
|
||||||
onDismiss = { showDefaultView = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showWeekStart) {
|
|
||||||
WeekStartPicker(
|
|
||||||
selected = state.weekStart,
|
|
||||||
// The preview is a real grid, so it uses the user's own month style.
|
|
||||||
monthStyle = state.monthViewStyle,
|
|
||||||
options = WEEK_START_OPTIONS,
|
|
||||||
onSelect = viewModel::setWeekStart,
|
|
||||||
onDismiss = { showWeekStart = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showTimeFormat) {
|
|
||||||
val locale = currentLocale()
|
|
||||||
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
|
||||||
OptionPicker(
|
|
||||||
title = stringResource(R.string.settings_time_format),
|
|
||||||
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
|
|
||||||
predictiveBack = true,
|
|
||||||
options = TimeFormatPref.entries,
|
|
||||||
selected = state.timeFormat,
|
|
||||||
label = { timeFormatLabel(it) },
|
|
||||||
// Each option renders the same sample time its own way;
|
|
||||||
// "Automatic" also names which of the two it resolves to.
|
|
||||||
summary = { pref ->
|
|
||||||
val sample = formatTimeOfDay(
|
|
||||||
hour = SAMPLE_HOUR,
|
|
||||||
minute = SAMPLE_MINUTE,
|
|
||||||
is24Hour = when (pref) {
|
|
||||||
TimeFormatPref.AUTO -> systemIs24Hour
|
|
||||||
TimeFormatPref.TWELVE_HOUR -> false
|
|
||||||
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
|
||||||
},
|
|
||||||
locale = locale,
|
|
||||||
)
|
|
||||||
if (pref == TimeFormatPref.AUTO) {
|
|
||||||
stringResource(R.string.settings_time_format_auto_summary, sample)
|
|
||||||
} else {
|
|
||||||
sample
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelect = viewModel::setTimeFormat,
|
|
||||||
onDismiss = { showTimeFormat = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showPastEvents) {
|
|
||||||
PastEventsPicker(
|
|
||||||
selected = state.pastEventDisplay,
|
|
||||||
onSelect = viewModel::setPastEventDisplay,
|
|
||||||
onDismiss = { showPastEvents = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showAgendaScreenRange) {
|
|
||||||
AgendaRangePicker(
|
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
|
||||||
description = stringResource(R.string.settings_agenda_range_hint),
|
|
||||||
selected = state.agendaScreenRange,
|
|
||||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
|
||||||
onSelect = viewModel::setAgendaScreenRange,
|
|
||||||
onDismiss = { showAgendaScreenRange = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sample time the format options render: afternoon, where 12h and 24h differ. */
|
|
||||||
private const val SAMPLE_HOUR = 14
|
|
||||||
private const val SAMPLE_MINUTE = 30
|
|
||||||
|
|
||||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
|
||||||
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
|
|
||||||
@Composable
|
|
||||||
private fun ViewRow(
|
|
||||||
view: CalendarView,
|
|
||||||
position: Position,
|
|
||||||
isDragging: Boolean,
|
|
||||||
dragHandle: Modifier,
|
|
||||||
dimmed: Boolean = false,
|
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(view.labelRes),
|
|
||||||
position = position,
|
|
||||||
dimmed = dimmed,
|
|
||||||
minHeight = ReorderableRowHeight,
|
|
||||||
// The reorderable column owns the inter-row spacing (uniform pitch).
|
|
||||||
gapBelow = false,
|
|
||||||
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = view.icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
trailing?.invoke()
|
|
||||||
if (trailing != null) Spacer(Modifier.width(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = dragHandle.size(48.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.DragHandle,
|
|
||||||
contentDescription = stringResource(R.string.reorder_drag_handle),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
|
||||||
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
|
||||||
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
|
||||||
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
|
||||||
// Localised weekday name, so no per-day string resource is needed.
|
|
||||||
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
|
|
||||||
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
|
||||||
when (pref) {
|
|
||||||
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
|
|
||||||
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
|
|
||||||
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun pastEventDisplayLabel(mode: PastEventDisplay): String =
|
|
||||||
stringResource(pastEventDisplayLabelRes(mode))
|
|
||||||
|
|
||||||
/** Shared with [PastEventsPicker], which needs the id rather than the string. */
|
|
||||||
@StringRes
|
|
||||||
internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) {
|
|
||||||
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
|
||||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
|
||||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.snap
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The week-start chooser, with the grid it rearranges above it — the same
|
|
||||||
* [MonthStylePreview] the Month style picker uses, drawn in the user's own month
|
|
||||||
* style. "Follow the system" carries the day it resolves to as its summary.
|
|
||||||
*
|
|
||||||
* A preview picker, so selecting applies at once and keeps the picker open.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun WeekStartPicker(
|
|
||||||
selected: WeekStartPref,
|
|
||||||
monthStyle: MonthViewStyle,
|
|
||||||
options: List<WeekStartPref>,
|
|
||||||
onSelect: (WeekStartPref) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val locale = currentLocale()
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
val resolved = selected.resolveFirstDay(locale)
|
|
||||||
FullScreenPicker(
|
|
||||||
title = stringResource(R.string.settings_week_start),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
.height(PREVIEW_HEIGHT),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Crossfade(
|
|
||||||
targetState = resolved,
|
|
||||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
|
||||||
label = "week-start-preview",
|
|
||||||
) { day ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(PREVIEW_SHAPE)
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
MonthStylePreview(
|
|
||||||
style = monthStyle,
|
|
||||||
weekStart = day,
|
|
||||||
height = PREVIEW_HEIGHT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
|
||||||
options.forEachIndexed { index, option ->
|
|
||||||
val isSelected = option == selected
|
|
||||||
GroupedRow(
|
|
||||||
title = weekStartLabel(option),
|
|
||||||
summary = if (option == WeekStartPref.Auto) {
|
|
||||||
stringResource(
|
|
||||||
R.string.settings_week_start_auto_summary,
|
|
||||||
weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
position = positionOf(index, options.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { onSelect(option) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Short enough to leave the first options on screen under it. */
|
|
||||||
private val PREVIEW_HEIGHT = 200.dp
|
|
||||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.app.StatusBarManager
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick
|
|
||||||
* Settings tile shortcut. The agenda widget keeps its own range and text size,
|
|
||||||
* separate from the Agenda screen's range in [ViewsScreen].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun WidgetsScreen(
|
|
||||||
state: SettingsUiState,
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
|
||||||
var showWidgetSize by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.settings_section_widgets),
|
|
||||||
onBack = onBack,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_agenda_widget_range),
|
|
||||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
|
||||||
position = Position.Top,
|
|
||||||
onClick = { showAgendaWidgetRange = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_widget_size),
|
|
||||||
summary = widgetSizeLabel(state.widgetSize),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showWidgetSize = true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// The add-tile prompt is API 33+; below that the tile is still
|
|
||||||
// addable from the QS editor, so the row just doesn't appear.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
QuickSettingsTileRow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAgendaWidgetRange) {
|
|
||||||
AgendaRangePicker(
|
|
||||||
title = stringResource(R.string.settings_agenda_widget_range),
|
|
||||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
|
||||||
selected = state.agendaWidgetRange,
|
|
||||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
|
||||||
onSelect = viewModel::setAgendaWidgetRange,
|
|
||||||
onDismiss = { showAgendaWidgetRange = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showWidgetSize) {
|
|
||||||
OptionPicker(
|
|
||||||
title = stringResource(R.string.settings_widget_size),
|
|
||||||
header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
|
|
||||||
predictiveBack = true,
|
|
||||||
options = WidgetSize.entries,
|
|
||||||
selected = state.widgetSize,
|
|
||||||
label = { widgetSizeLabel(it) },
|
|
||||||
// No live preview: the widget is Glance/RemoteViews, so a Compose
|
|
||||||
// mock-up would be a second implementation free to drift.
|
|
||||||
summary = { widgetSizeSummary(it) },
|
|
||||||
onSelect = viewModel::setWidgetSize,
|
|
||||||
onDismiss = { showWidgetSize = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
|
||||||
@Composable
|
|
||||||
private fun QuickSettingsTileRow() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_qs_tile),
|
|
||||||
summary = stringResource(R.string.settings_qs_tile_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
onClick = { requestAddQsTile(context) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
|
|
||||||
* shows its own confirmation dialog and handles the already-added case, so no
|
|
||||||
* result handling is needed here.
|
|
||||||
*/
|
|
||||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
|
||||||
private fun requestAddQsTile(context: Context) {
|
|
||||||
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
|
|
||||||
statusBar.requestAddTileService(
|
|
||||||
ComponentName(context, NewEventTileService::class.java),
|
|
||||||
context.getString(R.string.qs_tile_new_event_label),
|
|
||||||
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
|
|
||||||
context.mainExecutor,
|
|
||||||
) { /* result code unused — the system surfaces its own feedback */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a size step does, as a percentage of the smallest one's event text.
|
|
||||||
* Derived from the widget's own [metricsFor] table rather than hardcoded.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
|
||||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
|
||||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
|
||||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
|
||||||
when (size) {
|
|
||||||
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
|
||||||
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
|
||||||
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
|
||||||
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -74,8 +74,6 @@ sealed interface AgendaWidgetData {
|
|||||||
* from Glance state before an instance has its own state set.
|
* from Glance state before an instance has its own state set.
|
||||||
*/
|
*/
|
||||||
val savedShowToday: Boolean,
|
val savedShowToday: Boolean,
|
||||||
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
|
||||||
val savedWidgetSize: WidgetSize,
|
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -147,7 +145,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
savedRange = savedRange,
|
savedRange = savedRange,
|
||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
savedShowToday = showToday,
|
savedShowToday = showToday,
|
||||||
savedWidgetSize = prefs.widgetSize.first(),
|
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size tiers a widget scales its typography and metrics across (#51).
|
||||||
|
*
|
||||||
|
* Shared by every Glance widget so the bucketing rule can't drift between them:
|
||||||
|
* each widget keeps its own metrics table, but they all agree on *when* a widget
|
||||||
|
* counts as compact, regular, large or extra-large. Both widgets already declare
|
||||||
|
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
|
||||||
|
* size via `LocalSize.current` and passes it to [scaleFor].
|
||||||
|
*
|
||||||
|
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
|
||||||
|
* covered by plain JVM tests.
|
||||||
|
*/
|
||||||
|
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome a widget spends before its first content row: outer vertical padding
|
||||||
|
* plus a header row and its spacer. Subtracted from the raw height so the height
|
||||||
|
* thresholds below talk about *usable* space rather than gross widget height.
|
||||||
|
*/
|
||||||
|
private val CHROME_HEIGHT = 60.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets a live widget size into a [WidgetScale] by **width**.
|
||||||
|
*
|
||||||
|
* Width is the right axis: it governs how much of a title fits on a row, so it's
|
||||||
|
* what should drive type size. Height only decides how many rows are visible — a
|
||||||
|
* tall, narrow widget wants *more events*, not bigger text — so it never raises
|
||||||
|
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
|
||||||
|
* stepped back down so it can't keep oversized type in a sliver of space.
|
||||||
|
*
|
||||||
|
* The width thresholds are spread across the range a phone can actually produce
|
||||||
|
* (~180dp up to roughly the screen width) rather than over a theoretical range,
|
||||||
|
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
|
||||||
|
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
|
||||||
|
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
|
||||||
|
* surfaces — tablets, foldables, landscape — where the extra size reads well.
|
||||||
|
*
|
||||||
|
* The height cap is deliberately generous: it exists to catch a widget squashed
|
||||||
|
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
|
||||||
|
* the usual three cells tall (~270dp) must still reach the tier its width earned
|
||||||
|
* — that is exactly the resize #51 reports, and an aggressive cap would make the
|
||||||
|
* whole feature a no-op for it.
|
||||||
|
*/
|
||||||
|
internal fun scaleFor(size: DpSize): WidgetScale {
|
||||||
|
val byWidth = when {
|
||||||
|
size.width < 260.dp -> WidgetScale.COMPACT
|
||||||
|
size.width < 330.dp -> WidgetScale.REGULAR
|
||||||
|
size.width < 420.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
// Height can only ever pull the tier *down*, never push it up: a squashed
|
||||||
|
// widget would otherwise keep the big type its width earned and look absurd
|
||||||
|
// in the little space left. Keeping this a cap (rather than a second scaling
|
||||||
|
// axis) is what preserves "tall and narrow shows more events, not bigger
|
||||||
|
// text". Thresholds are usable height — roughly one, two and three rows of
|
||||||
|
// breathing room once the header is paid for.
|
||||||
|
val usable = size.height - CHROME_HEIGHT
|
||||||
|
val heightCap = when {
|
||||||
|
usable < 70.dp -> WidgetScale.COMPACT
|
||||||
|
usable < 130.dp -> WidgetScale.REGULAR
|
||||||
|
usable < 200.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
return minOf(byWidth, heightCap)
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The size step the **agenda** widget draws its text and rows at — a user
|
|
||||||
* setting, not the measured size (#51), which a launcher reports inaccurately.
|
|
||||||
* The month widget keeps sizing itself from the width it is given (#103).
|
|
||||||
*
|
|
||||||
* [SMALL] is the default and reproduces the widget's original constants.
|
|
||||||
*/
|
|
||||||
enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }
|
|
||||||
@@ -4,12 +4,14 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal layout constants for an agenda event row. These don't scale with the
|
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||||
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
|
* tier — a wider stripe or gap would eat title width, which is the thing the row
|
||||||
* is derived from them so non-event text can't drift out of alignment.
|
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||||
|
* the "nothing left today" line can never drift out of alignment with the event
|
||||||
|
* title column the way a hardcoded 19dp could.
|
||||||
*/
|
*/
|
||||||
internal val ROW_H_PAD = 4.dp
|
internal val ROW_H_PAD = 4.dp
|
||||||
internal val STRIPE_WIDTH = 5.dp
|
internal val STRIPE_WIDTH = 5.dp
|
||||||
@@ -19,9 +21,23 @@ internal val STRIPE_GAP = 10.dp
|
|||||||
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
* The width band a *default* agenda placement can land in, per
|
||||||
* shouldn't grow (the horizontal row constants above, corner radii) stay
|
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
|
||||||
* constants rather than routing through here.
|
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
|
||||||
|
* launcher cell grids vary, so the whole band — not one measured point — has to
|
||||||
|
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
|
||||||
|
* to hold. A test pins that.
|
||||||
|
*
|
||||||
|
* If the provider's `targetCellWidth` ever changes, this band and the first
|
||||||
|
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
|
||||||
|
* revisited together.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
|
||||||
|
* grow (the horizontal row constants above, corner radii) stay constants rather
|
||||||
|
* than routing through here.
|
||||||
*/
|
*/
|
||||||
internal data class AgendaMetrics(
|
internal data class AgendaMetrics(
|
||||||
val title: TextUnit, // header "Upcoming"
|
val title: TextUnit, // header "Upcoming"
|
||||||
@@ -37,24 +53,37 @@ internal data class AgendaMetrics(
|
|||||||
val dayHeaderTopPad: Dp, // space above a day header
|
val dayHeaderTopPad: Dp, // space above a day header
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Resolves the stripe height against the user's system font scale: the stripe
|
* Resolves the stripe height against the user's system font scale.
|
||||||
* is a [Dp] while the lines beside it are `sp`, so without this the text
|
*
|
||||||
* outgrows it at large font settings.
|
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
|
||||||
|
* they scale with the accessibility font setting and the stripe does not —
|
||||||
|
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
|
||||||
|
* it is supposed to mark. Multiplying by the same factor keeps them locked,
|
||||||
|
* and at the default scale of 1.0 reproduces the tier's value exactly.
|
||||||
*/
|
*/
|
||||||
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||||
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Type sizes are anchored to the Material 3 type scale, with two deviations:
|
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
|
||||||
|
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
|
||||||
|
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
|
||||||
*
|
*
|
||||||
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
|
* Two documented deviations:
|
||||||
* widget's original constants verbatim (#51).
|
*
|
||||||
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
|
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
|
||||||
* steps; interpolated values are used where a role would force a ≥1.4x jump.
|
* COMPACT reproduces the widget's original constants verbatim so a
|
||||||
|
* default-sized widget looks exactly as it did (#51), and snapping it to
|
||||||
|
* Title Small (14sp) would break that promise for a 1sp gain.
|
||||||
|
*
|
||||||
|
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
|
||||||
|
* which is far too coarse for four widget tiers. Where a role would force a
|
||||||
|
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
|
||||||
|
* mark it. The endpoints stay on real roles.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private val SMALL_METRICS = AgendaMetrics(
|
private val COMPACT_METRICS = AgendaMetrics(
|
||||||
title = 16.sp, // M3 Title Medium
|
title = 16.sp, // M3 Title Medium
|
||||||
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||||
eventTitle = 14.sp, // M3 Body Medium
|
eventTitle = 14.sp, // M3 Body Medium
|
||||||
@@ -68,7 +97,7 @@ private val SMALL_METRICS = AgendaMetrics(
|
|||||||
dayHeaderTopPad = 10.dp,
|
dayHeaderTopPad = 10.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val MEDIUM_METRICS = AgendaMetrics(
|
private val REGULAR_METRICS = AgendaMetrics(
|
||||||
title = 18.sp, // †
|
title = 18.sp, // †
|
||||||
dayHeader = 14.sp, // M3 Title Small
|
dayHeader = 14.sp, // M3 Title Small
|
||||||
eventTitle = 16.sp, // M3 Body Large
|
eventTitle = 16.sp, // M3 Body Large
|
||||||
@@ -96,7 +125,7 @@ private val LARGE_METRICS = AgendaMetrics(
|
|||||||
dayHeaderTopPad = 12.dp,
|
dayHeaderTopPad = 12.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
private val XLARGE_METRICS = AgendaMetrics(
|
||||||
title = 22.sp, // M3 Title Large
|
title = 22.sp, // M3 Title Large
|
||||||
dayHeader = 18.sp, // †
|
dayHeader = 18.sp, // †
|
||||||
eventTitle = 20.sp, // †
|
eventTitle = 20.sp, // †
|
||||||
@@ -110,12 +139,12 @@ private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
|||||||
dayHeaderTopPad = 14.dp,
|
dayHeaderTopPad = 14.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
|
||||||
private val AGENDA_METRICS = listOf(
|
private val AGENDA_METRICS = listOf(
|
||||||
SMALL_METRICS,
|
COMPACT_METRICS,
|
||||||
MEDIUM_METRICS,
|
REGULAR_METRICS,
|
||||||
LARGE_METRICS,
|
LARGE_METRICS,
|
||||||
EXTRA_LARGE_METRICS,
|
XLARGE_METRICS,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun metricsFor(size: WidgetSize): AgendaMetrics = AGENDA_METRICS[size.ordinal]
|
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.glance.GlanceModifier
|
|||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
import androidx.glance.Image
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
@@ -61,7 +62,7 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
@@ -104,23 +105,22 @@ internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
|
|
||||||
* in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
|
|
||||||
* setting reflects on the live widget without depending on the `provideGlance`
|
|
||||||
* preamble re-running.
|
|
||||||
*/
|
|
||||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
|
||||||
|
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
// Single: metrics come from the chosen WidgetSize, not the measured one
|
// Exact so the composition sees the widget's live size and can scale type/rows
|
||||||
// (#103, #51), so a RemoteViews per host size bucket would only double the
|
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
|
||||||
// payload. The row list stays capped below — an uncapped agenda could push
|
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
|
||||||
// the RemoteViews past the binder transaction limit.
|
// same.
|
||||||
override val sizeMode = SizeMode.Single
|
//
|
||||||
|
// Note Exact still asks Glance for one RemoteViews per host size (typically
|
||||||
|
// portrait + landscape) where the old SizeMode.Single produced exactly one —
|
||||||
|
// so the serialized payload roughly doubles. That is why the row list below is
|
||||||
|
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
|
||||||
|
// = 365) could otherwise push the RemoteViews past the binder transaction
|
||||||
|
// limit and the host would just show "Problem loading widget".
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
@@ -160,16 +160,12 @@ private sealed interface AgendaRow {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
// Read reactively from per-instance Glance state, falling back to the saved
|
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
|
||||||
// pref for a freshly placed widget (#103, #51).
|
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
|
||||||
// The stripe is then re-resolved against the system font scale so it tracks the
|
// 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.
|
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||||
val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL
|
|
||||||
val size = currentState(AGENDA_SIZE_KEY)
|
|
||||||
?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
|
|
||||||
?: savedSize
|
|
||||||
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||||
val metrics = metricsFor(size).scaledForFont(fontScale)
|
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -449,6 +449,8 @@
|
|||||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||||
<string name="dialog_save">Speichern</string>
|
<string name="dialog_save">Speichern</string>
|
||||||
|
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen – seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
|
||||||
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
|
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
|
||||||
<string name="calendars_enable_all">Alle aktivieren</string>
|
<string name="calendars_enable_all">Alle aktivieren</string>
|
||||||
<string name="calendars_disable_all">Alle deaktivieren</string>
|
<string name="calendars_disable_all">Alle deaktivieren</string>
|
||||||
|
|||||||
@@ -333,6 +333,8 @@
|
|||||||
<string name="calendars_local_header">Tus calendarios</string>
|
<string name="calendars_local_header">Tus calendarios</string>
|
||||||
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
|
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
|
||||||
<string name="calendars_add">Añadir calendario</string>
|
<string name="calendars_add">Añadir calendario</string>
|
||||||
|
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
|
||||||
<string name="calendars_synced_header">Calendarios sincronizados</string>
|
<string name="calendars_synced_header">Calendarios sincronizados</string>
|
||||||
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
|
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
|
||||||
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
|
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
|
||||||
|
|||||||
@@ -400,6 +400,8 @@
|
|||||||
<string name="calendars_local_header">Vos calendriers</string>
|
<string name="calendars_local_header">Vos calendriers</string>
|
||||||
<string name="calendars_local_empty">Pas encore de calendrier local. Créez-en un pour conserver les événements uniquement sur cet appareil.</string>
|
<string name="calendars_local_empty">Pas encore de calendrier local. Créez-en un pour conserver les événements uniquement sur cet appareil.</string>
|
||||||
<string name="calendars_add">Ajouter un calendrier</string>
|
<string name="calendars_add">Ajouter un calendrier</string>
|
||||||
|
<string name="calendars_disable_hint">Désactivez un calendrier pour le retirer de l’application, ses événements, ses filtres et ses sélecteurs. Rien n’est supprimé et vous pouvez le réactiver à tout moment ici.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Afficher « %1$s » dans l’application</string>
|
||||||
<string name="calendars_synced_header">Calendriers synchronisés</string>
|
<string name="calendars_synced_header">Calendriers synchronisés</string>
|
||||||
<string name="calendars_synced_hint">Ils proviennent de comptes sur votre appareil. Créez et modifiez-les dans leur propre application.</string>
|
<string name="calendars_synced_hint">Ils proviennent de comptes sur votre appareil. Créez et modifiez-les dans leur propre application.</string>
|
||||||
<string name="calendars_manage_in_app">Gérer dans l’application</string>
|
<string name="calendars_manage_in_app">Gérer dans l’application</string>
|
||||||
|
|||||||
@@ -317,6 +317,8 @@
|
|||||||
<string name="calendars_local_header">Calendari locali</string>
|
<string name="calendars_local_header">Calendari locali</string>
|
||||||
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno con eventi salvati solo su questo dispositivo.</string>
|
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno con eventi salvati solo su questo dispositivo.</string>
|
||||||
<string name="calendars_add">Aggiungi calendario</string>
|
<string name="calendars_add">Aggiungi calendario</string>
|
||||||
|
<string name="calendars_disable_hint">Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
|
||||||
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
||||||
<string name="calendars_synced_hint">Questi calendari vengono dagli account sincronizzati sul tuo dispositivo. Puoi modificarli o crearne di nuovi dall\'app di sincronizzazione.</string>
|
<string name="calendars_synced_hint">Questi calendari vengono dagli account sincronizzati sul tuo dispositivo. Puoi modificarli o crearne di nuovi dall\'app di sincronizzazione.</string>
|
||||||
<string name="calendars_manage_in_app">Gestisci in app</string>
|
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||||
|
|||||||
@@ -396,6 +396,8 @@
|
|||||||
<string name="calendars_local_header">Twoje kalendarze</string>
|
<string name="calendars_local_header">Twoje kalendarze</string>
|
||||||
<string name="calendars_local_empty">Brak lokalnych kalendarzy. Utwórz kalendarz, aby zapisywać wydarzenia tylko na tym urządzeniu.</string>
|
<string name="calendars_local_empty">Brak lokalnych kalendarzy. Utwórz kalendarz, aby zapisywać wydarzenia tylko na tym urządzeniu.</string>
|
||||||
<string name="calendars_add">Dodaj kalendarz</string>
|
<string name="calendars_add">Dodaj kalendarz</string>
|
||||||
|
<string name="calendars_disable_hint">Wyłącz kalendarz, aby ukryć go w aplikacji — wraz z jego wydarzeniami, filtrami i selektorami. Nic nie zostanie usunięte, a w każdej chwili możesz go tutaj ponownie włączyć.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Pokaż „%1$s” w aplikacji</string>
|
||||||
<string name="calendars_synced_header">Synchronizowane kalendarze</string>
|
<string name="calendars_synced_header">Synchronizowane kalendarze</string>
|
||||||
<string name="calendars_synced_hint">Pochodzą z kont na Twoim urządzeniu. Możesz je tworzyć i edytować w powiązanych z nimi aplikacjach.</string>
|
<string name="calendars_synced_hint">Pochodzą z kont na Twoim urządzeniu. Możesz je tworzyć i edytować w powiązanych z nimi aplikacjach.</string>
|
||||||
<string name="calendars_manage_in_app">Zarządzaj w aplikacji</string>
|
<string name="calendars_manage_in_app">Zarządzaj w aplikacji</string>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user