Compare commits
20 Commits
release/v2
...
761b299e6a
| Author | SHA1 | Date | |
|---|---|---|---|
| 761b299e6a | |||
| 4bdcf20c82 | |||
| 9bcd08a5bb | |||
|
|
ca2069da0d | ||
|
|
02cf32b537 | ||
|
|
e44d70e18b | ||
|
|
401e067945 | ||
|
|
a0827202b3 | ||
|
|
36bbb3ff2e | ||
|
|
1d07b64a28 | ||
| d037492cf6 | |||
| 8e2109d073 | |||
| bf6415c023 | |||
| 7aef01d95e | |||
| ce4d6bc4d1 | |||
| 4ad805e747 | |||
| bb6e3ad336 | |||
| edbeadfa30 | |||
| ef48717e2c | |||
| 41593a0e9d |
@@ -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
|
||||||
@@ -264,10 +258,9 @@ jobs:
|
|||||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||||
|
|
||||||
# Per-version "What's New": ensure this version's changelog exists in the
|
# Per-version "What's New": ensure this version's changelog exists in the
|
||||||
# fastlane tree. The committed hand-written summary (kept under Play's
|
# fastlane tree (committed at release-cut time for the official repo; this
|
||||||
# 500-char cap) is used as-is; only if it is missing does the script fall
|
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
||||||
# back to CHANGELOG.md, so the self-hosted repo never depends on the
|
# the commit having happened). The transform below then carries it across.
|
||||||
# commit having happened. The transform below then carries it across.
|
|
||||||
- name: Ensure this version's changelog is in the fastlane tree
|
- name: Ensure this version's changelog is in the fastlane tree
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||||
@@ -484,172 +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
|
|
||||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
|
||||||
# Absolute, because a lane body runs from fastlane/, not the
|
|
||||||
# workspace root — a relative path resolves against the wrong
|
|
||||||
# directory there and 2.17.1 died on exactly that.
|
|
||||||
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
|
||||||
bundle exec fastlane deploy \
|
|
||||||
aab:"$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**.
|
||||||
171
CHANGELOG.md
171
CHANGELOG.md
@@ -7,165 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Month-view event titles no longer end in an ellipsis. The "…" took the width
|
|
||||||
of a couple of characters and told you nothing you couldn't already see, so
|
|
||||||
the title now simply runs to the edge of its chip — a few more letters per
|
|
||||||
cell, which is often the difference between two events you can tell apart and
|
|
||||||
two you can't ([#164]).
|
|
||||||
|
|
||||||
## [2.19.1] — 2026-08-11
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
|
|
||||||
reschedule*, on by default. Off, no event can be picked up in the month, week
|
|
||||||
or day view — for anyone who kept moving appointments by accident while
|
|
||||||
scrolling ([#173]).
|
|
||||||
- An early Brazilian Portuguese translation. Calendula has started speaking
|
|
||||||
Brazilian Portuguese, contributed as a community translation through
|
|
||||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
|
||||||
About a third of the app is covered, so the rest still shows in English — you
|
|
||||||
can pick it under Settings → Language or in Android's per-app language
|
|
||||||
settings. Thanks to
|
|
||||||
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
|
|
||||||
started; help finishing it is very welcome.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **The language picker forgot which language was picked.** Android hands the
|
|
||||||
applied language back with its script filled in — Simplified Chinese goes in
|
|
||||||
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
|
|
||||||
Settings → Language showed as selected, and the row above it named the
|
|
||||||
language the long way round. Only languages that imply a script were
|
|
||||||
affected.
|
|
||||||
|
|
||||||
## [2.19.0] — 2026-08-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Drag an event to reschedule it.** Press and hold an event in the month,
|
|
||||||
week or day view and drop it on another time or day — the only way to move an
|
|
||||||
appointment used to be opening it and editing its start and end by hand. The
|
|
||||||
block follows your finger, the timeline scrolls when you carry it to an edge,
|
|
||||||
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
|
|
||||||
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
|
|
||||||
view it moves the event to the day you dropped it on and leaves the time
|
|
||||||
alone. A confirmation names the new slot and offers **Undo** wherever the move
|
|
||||||
can be taken back in one step. Events you can't edit — read-only calendars, and
|
|
||||||
birthdays and anniversaries mirrored from your contacts — can't be picked up.
|
|
||||||
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
|
|
||||||
every event that can be moved ([#68]).
|
|
||||||
- Dragging a **recurring** event asks what it should apply to, the same *this
|
|
||||||
event / this and following / all events* choice a save does. Where moving the
|
|
||||||
whole series would change which weekday it falls on, the rule is rewritten to
|
|
||||||
match — a weekly Monday event dropped on a Wednesday becomes a weekly
|
|
||||||
Wednesday event. Where it can't be rewritten safely, the prompt says so and
|
|
||||||
offers to move just the one occurrence, rather than quietly leaving the
|
|
||||||
series behind ([#68]).
|
|
||||||
- **Delete several search results at once.** Long-press a hit in search to enter
|
|
||||||
selection mode, tap the rest (or **Select all**), and delete the lot in one
|
|
||||||
action — finding and removing, say, every leftover event with the same title
|
|
||||||
no longer means opening them one at a time. Recurring hits in the batch ask
|
|
||||||
once for the whole selection instead of once each, results on read-only
|
|
||||||
calendars can't be selected, and the deletion is confirmed before it runs
|
|
||||||
([#80]).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **First launch is now a short wizard** instead of a single permission screen.
|
|
||||||
It counts its steps, each one can be stepped back out of, and beyond granting
|
|
||||||
calendar access it offers two things worth deciding once: turning on automatic
|
|
||||||
backup — chosen inline, folder picker and all, since events that live only on
|
|
||||||
the phone are lost with it — and choosing which view Calendula opens on, with
|
|
||||||
a live preview of the month, week, day and agenda layouts rather than four
|
|
||||||
names in a list. Both are skippable, everything chosen is changeable in
|
|
||||||
Settings afterwards, and the backup step is offered only where nothing
|
|
||||||
writable is being synced anywhere. Existing installs are not re-onboarded —
|
|
||||||
revoking and re-granting the calendar permission later does not restart the
|
|
||||||
wizard ([#163]).
|
|
||||||
- The one-time notice about calendars switched off for this device is now a step
|
|
||||||
in the flow rather than a dialog thrown over the app on first open.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
|
|
||||||
attributes the Play Console flags on Android 15 and up, where they no longer
|
|
||||||
do anything. The launch backdrop now picks its status-bar icons from the
|
|
||||||
light/dark resource instead, so they stay legible over it either way
|
|
||||||
([#123]).
|
|
||||||
|
|
||||||
## [2.18.1] — 2026-08-06
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Tapping an empty slot in week or day view creates the event at the hour you
|
|
||||||
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
|
||||||
— or changing **Hour height** in Settings — left the tap still being measured
|
|
||||||
against the old spacing, so the new event landed at some other hour entirely
|
|
||||||
([#148]).
|
|
||||||
|
|
||||||
## [2.18.0] — 2026-07-31
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- A new event no longer always lasts an hour. **Settings → New event form →
|
|
||||||
Default duration** sets how long one opens, and each calendar may keep its own
|
|
||||||
length underneath — 8 hours for the calendar you keep work shifts in, 30
|
|
||||||
minutes for the one you book calls in. A calendar without its own length
|
|
||||||
follows the default, switching calendars mid-form re-stretches the event, and
|
|
||||||
setting an end time by hand keeps it. An event another app hands over with only
|
|
||||||
a start gets the default too; one that names its own end — an `.ics` file, a
|
|
||||||
duplicate — keeps that length. All-day events are unaffected ([#54]).
|
|
||||||
- Week and day view can be set to show more or less of the day at once, under
|
|
||||||
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
|
|
||||||
hour to your screen so all 24 hours are visible without scrolling — on a tall
|
|
||||||
phone the old fixed spacing showed only about half a day, so appointments
|
|
||||||
could sit below the fold all week. Compact and Comfortable are fixed steps
|
|
||||||
either side of the previous spacing, which stays the default. Both views share
|
|
||||||
the setting ([#56]).
|
|
||||||
- Week and day view can be **pinched** with two fingers to set the hour height
|
|
||||||
directly, anywhere between and beyond the named steps. The time under your
|
|
||||||
fingers stays put as it zooms, so you keep your place in the day. A pinched
|
|
||||||
height is remembered and appears as **Custom** in the Hour height setting, so
|
|
||||||
tapping a named step there takes you back to it ([#56]).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- The date in the top bar is now the way to jump: tapping the month, week or day
|
|
||||||
title opens the same date picker the sidebar's **Jump to date** offers, seeded
|
|
||||||
on whatever the bar is naming. A drop-down caret marks it as tappable. The
|
|
||||||
sidebar entry stays where it is ([#57]).
|
|
||||||
- A reminder in the status bar is now Calendula's own mark — the calendar with
|
|
||||||
the bloom — instead of the generic calendar glyph it shared with the system's
|
|
||||||
date surfaces and every other calendar app. The status bar draws that icon as
|
|
||||||
a plain silhouette, so its shape is the only thing that could tell them apart
|
|
||||||
([#83]).
|
|
||||||
- Event blocks now only draw text they can draw whole. One too short for a full
|
|
||||||
line shows no title rather than a sliced one, a block that cannot fit both its
|
|
||||||
title and its time keeps the title, and a block too narrow to hold more than a
|
|
||||||
syllable stays on one ellipsised line instead of stacking letters down the
|
|
||||||
block. Tapping and the spoken description are unchanged ([#56]).
|
|
||||||
- Calendar colours are reworked so text on an event is always readable. They
|
|
||||||
were shaped by a brightness setting that does not match what the eye sees, so
|
|
||||||
whether a block got dark or light text depended on which hue you happened to
|
|
||||||
pick — an orange calendar took dark text while a red one beside it took light
|
|
||||||
— and colours landing in between were hard to read either way. Each colour now
|
|
||||||
keeps its hue and is moved clear of that middle: most become deep blocks with
|
|
||||||
light text, while naturally pale colours such as yellow stay pale and take
|
|
||||||
dark text, rather than being forced into a muddy brown. Dots, stripes and
|
|
||||||
icons are tuned separately from blocks, so they stay visible against the
|
|
||||||
background instead of sharing a colour meant to sit behind text. The setting
|
|
||||||
is now called **Harmonise calendar colours**; turning it off still shows the
|
|
||||||
raw colours from your calendar source ([#21], [#36]).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- The back gesture closes the sidebar instead of the app. With the drawer open,
|
|
||||||
swiping back left Calendula altogether rather than putting the sidebar away
|
|
||||||
([#114]).
|
|
||||||
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
|
|
||||||
edge of the rows under them, and the view, jump-to-date, Settings and calendar
|
|
||||||
rows sit on one vertical axis instead of each icon finding its own — the same
|
|
||||||
alignment the Settings screens use ([#114]).
|
|
||||||
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
|
|
||||||
Setting the app dark while the system stayed light — or the other way round —
|
|
||||||
left the clock and battery drawn for the system's theme, so they could sit
|
|
||||||
near-invisible against the app's own bar ([#70]).
|
|
||||||
|
|
||||||
## [2.17.1] — 2026-07-30
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Settings → Calendars now says what is different about a calendar instead of
|
- 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
|
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||||
@@ -1406,19 +1247,7 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
|
|
||||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||||
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
|
|
||||||
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
|
||||||
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
|
||||||
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
|
||||||
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
|
||||||
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
|
|
||||||
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
|
|
||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
|
||||||
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
|
||||||
|
|||||||
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 = 21901
|
versionCode = 21600
|
||||||
versionName = "2.19.1"
|
versionName = "2.16.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
@@ -2,11 +2,9 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import androidx.activity.SystemBarStyle
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -14,7 +12,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
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
|
||||||
@@ -32,14 +29,11 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
|
||||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -56,17 +50,9 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** A prefilled create form from an external launch, with the source it came from. */
|
|
||||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// Which of light/dark the system bars are drawn for. The styles installed
|
|
||||||
// in onCreate read this field live, so androidx's config-change replay
|
|
||||||
// picks up the in-app override instead of the night resource qualifier.
|
|
||||||
private var systemBarsDark = false
|
|
||||||
|
|
||||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
// tap into the running activity; CalendarHost consumes and clears it.
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
@@ -83,7 +69,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||||
// CalendarHost, which opens it in the create form for review.
|
// CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||||
@@ -110,13 +96,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
enableEdgeToEdge()
|
||||||
Configuration.UI_MODE_NIGHT_YES
|
|
||||||
applyEdgeToEdge()
|
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsert = intent.insertRequestOrNull()
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
@@ -129,14 +113,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
ThemeMode.DARK -> true
|
||||||
}
|
}
|
||||||
// onCreate can only see the night resource qualifier, not the in-app
|
|
||||||
// override — re-apply from the resolved theme so the bar icons follow
|
|
||||||
// the app's light/dark choice.
|
|
||||||
DisposableEffect(darkTheme) {
|
|
||||||
systemBarsDark = darkTheme
|
|
||||||
applyEdgeToEdge()
|
|
||||||
onDispose {}
|
|
||||||
}
|
|
||||||
// The app-wide clock convention: the time-format preference resolved
|
// The app-wide clock convention: the time-format preference resolved
|
||||||
// against the device's 24-hour system setting, provided once here so
|
// against the device's 24-hour system setting, provided once here so
|
||||||
// every time label reads it via LocalUse24HourFormat.
|
// every time label reads it via LocalUse24HourFormat.
|
||||||
@@ -149,13 +125,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// re-import stamp AppFontSettings carries, so replacing the file
|
// re-import stamp AppFontSettings carries, so replacing the file
|
||||||
// behind an active "custom" token still refreshes — changes;
|
// behind an active "custom" token still refreshes — changes;
|
||||||
// "system for both" returns the default scale untouched.
|
// "system for both" returns the default scale untouched.
|
||||||
// The timeline scale plus the pinch in flight over it (#56). Held
|
|
||||||
// here, above the calendar views, so a zoom survives paging between
|
|
||||||
// weeks and switching between the week and day view.
|
|
||||||
val timelineZoom = rememberTimelineZoom(
|
|
||||||
stored = settings.timelineScale,
|
|
||||||
onPersist = settingsViewModel::setTimelineScale,
|
|
||||||
)
|
|
||||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
val typography = remember(fonts, context) {
|
val typography = remember(fonts, context) {
|
||||||
calendulaTypography(
|
calendulaTypography(
|
||||||
@@ -172,7 +141,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
LocalTimelineZoom provides timelineZoom,
|
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
@@ -183,9 +151,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsert?.form,
|
requestedInsertForm = requestedInsertForm,
|
||||||
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
onInsertConsumed = { requestedInsert = null },
|
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
@@ -214,19 +181,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
|
|
||||||
* Both scrims are transparent because API 29+ enforces its own contrast and
|
|
||||||
* ignores them anyway.
|
|
||||||
*/
|
|
||||||
private fun applyEdgeToEdge() {
|
|
||||||
val transparent = android.graphics.Color.TRANSPARENT
|
|
||||||
enableEdgeToEdge(
|
|
||||||
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
|
||||||
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
@@ -238,7 +192,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,10 +219,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||||
* [buildInsertEventForm]. An intent that names no end time leaves the length
|
* [buildInsertEventForm].
|
||||||
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
|
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertRequestOrNull(): InsertRequest? {
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||||
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||||
// as is any plain ACTION_INSERT.
|
// as is any plain ACTION_INSERT.
|
||||||
@@ -276,11 +229,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||||
editEventKeyOrNull() == null)
|
editEventKeyOrNull() == null)
|
||||||
if (!isCreate) return null
|
if (!isCreate) return null
|
||||||
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
|
return buildInsertEventForm(
|
||||||
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
val form = buildInsertEventForm(
|
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||||
beginMillis = beginMillis,
|
|
||||||
endMillis = endMillis,
|
|
||||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||||
@@ -289,10 +240,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
zone = TimeZone.currentSystemDefault(),
|
zone = TimeZone.currentSystemDefault(),
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
// An end the intent didn't name — or one [buildInsertEventForm] drops for
|
|
||||||
// landing before the start — leaves the length to the setting.
|
|
||||||
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
|
|
||||||
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Long extra's value, or null when the extra is absent. */
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
|
|||||||
@@ -27,10 +27,8 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
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.domain.EventSearch
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
@@ -64,14 +62,12 @@ interface CalendarDataSource {
|
|||||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master/one-off events that may match [query], across all calendars: every
|
* Master/one-off events whose title, description or location contains
|
||||||
* whitespace-separated token has to appear in the title, description or
|
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
||||||
* location. Reads the Events table directly so the search is unbounded in
|
* Events table directly so the search is unbounded in time; exception rows
|
||||||
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
||||||
* superset — [EventSearch] makes the final call and orders the hits.
|
|
||||||
* [query] is assumed non-blank.
|
|
||||||
*/
|
*/
|
||||||
fun searchEvents(query: String): List<SearchCandidate>
|
fun searchEvents(query: String): List<EventInstance>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
@@ -589,23 +585,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
override fun searchEvents(query: String): List<EventInstance> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
val trimmed = query.trim()
|
||||||
if (tokens.isEmpty()) return emptyList()
|
if (trimmed.isEmpty()) return emptyList()
|
||||||
// Only a pre-filter: EventSearch re-checks every token, including the
|
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
||||||
// ones the cap above dropped.
|
// itself instead of acting as a wildcard.
|
||||||
val patterns = tokens.map { likePatterns(it) }
|
val escaped = trimmed
|
||||||
val match = patterns.joinToString(" AND ") { variants ->
|
.replace("\\", "\\\\")
|
||||||
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
|
.replace("%", "\\%")
|
||||||
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
.replace("_", "\\_")
|
||||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
val like = "%$escaped%"
|
||||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||||
}
|
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||||
}
|
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||||
val args = patterns
|
|
||||||
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
|
|
||||||
.toTypedArray()
|
|
||||||
val selection = "($match) AND " +
|
val selection = "($match) AND " +
|
||||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||||
@@ -613,17 +606,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
SearchProjection.COLUMNS,
|
SearchProjection.COLUMNS,
|
||||||
selection,
|
selection,
|
||||||
args,
|
arrayOf(like, like, like),
|
||||||
CalendarContract.Events.DTSTART + " DESC",
|
CalendarContract.Events.DTSTART + " DESC",
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
val out = ArrayList<SearchCandidate>(c.count)
|
val out = ArrayList<EventInstance>(c.count)
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
|
||||||
val base = reader.toSearchResult() ?: continue
|
val base = reader.toSearchResult() ?: continue
|
||||||
// A recurring master's DTSTART is the series start; date the row
|
// A recurring master's DTSTART is the series start; show its
|
||||||
// by its nearest occurrence instead.
|
// nearest occurrence instead so the date is the one the user
|
||||||
val event = if (base.isRecurring) {
|
// actually cares about (and sorting reflects it).
|
||||||
|
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
|
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
||||||
|
out += if (recurring) {
|
||||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||||
base.copy(
|
base.copy(
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
@@ -633,44 +628,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
// The raw title, so matching can't catch on the placeholder an
|
|
||||||
// untitled event is drawn with.
|
|
||||||
out += SearchCandidate(
|
|
||||||
event = event,
|
|
||||||
description = description,
|
|
||||||
title = reader.getString(SearchProjection.IDX_TITLE),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One token as the `LIKE` patterns to OR together. SQLite folds case for
|
|
||||||
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
|
|
||||||
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
|
|
||||||
* those letters away instead would make "москва" `%______%` — a full scan.
|
|
||||||
*/
|
|
||||||
private fun likePatterns(token: String): List<String> {
|
|
||||||
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
|
|
||||||
return listOf(likePattern(token))
|
|
||||||
}
|
|
||||||
val lower = token.lowercase()
|
|
||||||
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
|
|
||||||
.distinct()
|
|
||||||
.map(::likePattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun likePattern(token: String): String {
|
|
||||||
val sb = StringBuilder("%")
|
|
||||||
for (c in token) {
|
|
||||||
// A literal wildcard from the query matches itself.
|
|
||||||
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
|
|
||||||
sb.append(c)
|
|
||||||
}
|
|
||||||
return sb.append('%').toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||||
@@ -1631,12 +1593,5 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* next fires beyond it falls back to its series-start date.
|
* next fires beyond it falls back to its series-start date.
|
||||||
*/
|
*/
|
||||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
|
||||||
* pasted paragraph; the ranker still requires every token, so a capped
|
|
||||||
* search returns fewer rows to check, never more hits.
|
|
||||||
*/
|
|
||||||
const val MAX_SEARCH_TOKENS = 8
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
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.domain.SearchCandidate
|
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
@@ -18,11 +17,11 @@ interface CalendarRepository {
|
|||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Candidate matches for [query] with hidden calendars removed; empty when
|
* Events whose title, description or location contains [query], with hidden
|
||||||
* [query] is blank. Searches the whole history/future and leaves matching
|
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||||
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||||
*/
|
*/
|
||||||
suspend fun searchEvents(query: String): List<SearchCandidate>
|
suspend fun searchEvents(query: String): List<EventInstance>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette a calendar's account publishes; empty when it
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
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.domain.SearchCandidate
|
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -163,13 +162,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
?: throw NoSuchEventException(eventId)
|
?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = 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.pendingDisabledCalendarIds.first() +
|
prefs.pendingDisabledCalendarIds.first() +
|
||||||
invisibleCalendarIds()
|
invisibleCalendarIds()
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
eventColor = eventColor,
|
eventColor = eventColor,
|
||||||
eventColorKey = eventColorKey,
|
eventColorKey = eventColorKey,
|
||||||
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,6 @@ internal object EventDetailProjection {
|
|||||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
// needs it to render a series opened without a named occurrence.
|
// needs it to render a series opened without a named occurrence.
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
// Non-null on a modified-occurrence exception row; "no RRULE" alone
|
|
||||||
// can't tell an exception from a master (#68).
|
|
||||||
CalendarContract.Events.ORIGINAL_ID,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -113,7 +110,6 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
const val IDX_DURATION = 18
|
const val IDX_DURATION = 18
|
||||||
const val IDX_ORIGINAL_ID = 19
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,8 +176,6 @@ internal object SearchProjection {
|
|||||||
// display its nearest occurrence, not the series-start DTSTART.
|
// display its nearest occurrence, not the series-start DTSTART.
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
CalendarContract.Events.RDATE,
|
CalendarContract.Events.RDATE,
|
||||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
|
||||||
CalendarContract.Events.DESCRIPTION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -196,7 +190,6 @@ internal object SearchProjection {
|
|||||||
const val IDX_LOCATION = 9
|
const val IDX_LOCATION = 9
|
||||||
const val IDX_RRULE = 10
|
const val IDX_RRULE = 10
|
||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
const val IDX_DESCRIPTION = 12
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,7 +40,5 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
|
||||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.common.TimelineScale
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
|
||||||
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 de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
@@ -273,19 +270,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
|
||||||
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
|
|
||||||
* or the height a pinch on the timeline settled at.
|
|
||||||
*/
|
|
||||||
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
|
||||||
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setTimelineScale(scale: TimelineScale) {
|
|
||||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
@@ -301,19 +285,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether events can be dragged to another slot in the calendar views (#68,
|
|
||||||
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
|
|
||||||
* rescheduling then goes through the edit form.
|
|
||||||
*/
|
|
||||||
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setDragToReschedule(enabled: Boolean) {
|
|
||||||
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -486,46 +457,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a new **timed** event lasts, in minutes (#54). Defaults to
|
|
||||||
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
|
|
||||||
* overrides in [perCalendarEventDuration] take precedence; all-day events are
|
|
||||||
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
|
|
||||||
*/
|
|
||||||
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
|
|
||||||
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
|
|
||||||
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
|
|
||||||
store.edit {
|
|
||||||
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
|
|
||||||
* id: a calendar **present** in the map gives its new events that length, one
|
|
||||||
* **absent** inherits the global default (there is no "no duration", so this
|
|
||||||
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
|
|
||||||
*/
|
|
||||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
|
|
||||||
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [minutes] null drops the override, so the calendar inherits the global default. */
|
|
||||||
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
|
||||||
store.edit { prefs ->
|
|
||||||
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
|
|
||||||
if (minutes == null) {
|
|
||||||
current.remove(calendarId)
|
|
||||||
} else {
|
|
||||||
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
|
||||||
}
|
|
||||||
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
* for users whose only calendar app this is, reminders are essential; the
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
@@ -563,87 +494,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */
|
suspend fun setReminderOnboardingDone() {
|
||||||
suspend fun setReminderOnboardingDone(done: Boolean = true) {
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this install is a fresh one still owing the wizard's optional
|
|
||||||
* steps (#163). Armed on any launch that holds the calendar permission and
|
|
||||||
* has not finished the reminder step, and cleared again by
|
|
||||||
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
|
||||||
* the permission isn't re-onboarded.
|
|
||||||
*/
|
|
||||||
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun armOnboardingWizard() {
|
|
||||||
store.edit { prefs ->
|
|
||||||
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
|
||||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setOnboardingBackupDone(done: Boolean = true) {
|
|
||||||
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the wizard's default-view step has been answered (or skipped). */
|
|
||||||
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_VIEW_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setOnboardingViewDone(done: Boolean = true) {
|
|
||||||
store.edit { it[ONBOARDING_VIEW_KEY] = done }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the wizard's month-style step has been answered (or skipped). */
|
|
||||||
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
|
|
||||||
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the wizard's calendar-visibility notice has been read. Separate
|
|
||||||
* from [CalendarPrefs.visibilityNoticePending], which says whether this
|
|
||||||
* install has anything to announce at all.
|
|
||||||
*/
|
|
||||||
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
|
|
||||||
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the wizard's closing screen has been seen. */
|
|
||||||
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_DONE_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
|
|
||||||
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the wizard for good: the closing screen has been seen and the
|
|
||||||
* install stops counting as fresh, so a later revoke-and-re-grant is only
|
|
||||||
* the permission screen. One edit, so the plan can't see it half-closed.
|
|
||||||
*/
|
|
||||||
suspend fun finishOnboardingWizard() {
|
|
||||||
store.edit { prefs ->
|
|
||||||
prefs[ONBOARDING_DONE_KEY] = true
|
|
||||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -956,33 +808,14 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
|
||||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
|
|
||||||
internal val CALENDAR_EVENT_DURATION_KEY =
|
|
||||||
stringPreferencesKey("per_calendar_event_duration")
|
|
||||||
|
|
||||||
/** A new timed event's length until the user changes it: one hour. */
|
|
||||||
const val DEFAULT_EVENT_DURATION = 60
|
|
||||||
internal const val MIN_EVENT_DURATION = 1
|
|
||||||
/** A day — past that the form is describing a multi-day event, not a default. */
|
|
||||||
internal const val MAX_EVENT_DURATION = 1_440
|
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
|
||||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
|
||||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
|
||||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
|
||||||
booleanPreferencesKey("onboarding_month_style_done")
|
|
||||||
internal val ONBOARDING_VISIBILITY_KEY =
|
|
||||||
booleanPreferencesKey("onboarding_visibility_done")
|
|
||||||
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
|
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
@@ -1072,17 +905,6 @@ fun resolveDefaultReminder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The length a new timed event on [calendarId] opens with: that calendar's
|
|
||||||
* override if it has one, otherwise the [global] default. Pure so it can be
|
|
||||||
* unit-tested.
|
|
||||||
*/
|
|
||||||
fun resolveDefaultEventDuration(
|
|
||||||
global: Int,
|
|
||||||
overrides: Map<Long, Int>,
|
|
||||||
calendarId: Long?,
|
|
||||||
): Int = calendarId?.let { overrides[it] } ?: global
|
|
||||||
|
|
||||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private const val WEEK_START_AUTO = "AUTO"
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
|
|
||||||
@@ -1130,27 +952,6 @@ private fun String?.toReminderList(): List<Int> = when {
|
|||||||
private fun List<Int>.toStoredReminders(): String =
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
|
|
||||||
* Malformed entries and out-of-range lengths are dropped, so a garbled value
|
|
||||||
* degrades to "inherits the global default" instead of throwing.
|
|
||||||
*/
|
|
||||||
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
|
||||||
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
|
||||||
val parts = entry.split(KEY_VALUE_SEP)
|
|
||||||
if (parts.size != 2) return@mapNotNull null
|
|
||||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
|
||||||
val minutes = parts[1].trim().toIntOrNull()
|
|
||||||
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
|
|
||||||
?: return@mapNotNull null
|
|
||||||
calendarId to minutes
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
private fun Map<Long, Int>.toStoredDurations(): String =
|
|
||||||
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
|
||||||
"$id$KEY_VALUE_SEP$minutes"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -44,14 +44,6 @@ val CalendarSource.hasVisibilitySwitch: Boolean
|
|||||||
val CalendarSource.isEventTarget: Boolean
|
val CalendarSource.isEventTarget: Boolean
|
||||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
|
||||||
* Deliberately not [isEventTarget]: a managed event stays editable (reminders,
|
|
||||||
* notes) yet must never move, since the next contacts sync would put it back.
|
|
||||||
*/
|
|
||||||
val CalendarSource.allowsEventMove: Boolean
|
|
||||||
get() = canModifyContents && !isManaged
|
|
||||||
|
|
||||||
/** Every state worth naming on this calendar's row, in reading order. */
|
/** Every state worth naming on this calendar's row, in reading order. */
|
||||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
import kotlin.math.abs
|
||||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
import kotlin.math.atan2
|
||||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
import kotlin.math.cbrt
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curates an account's published event palette for the colour picker.
|
* Curates an account's published event palette for the colour picker.
|
||||||
@@ -14,25 +18,24 @@ import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
|||||||
* (#22).
|
* (#22).
|
||||||
*
|
*
|
||||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||||
* the raw provider value — and it gets that colour from the same [eventTone]
|
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||||
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||||
* Because a harmonised container pins lightness, the raw palette's lightness
|
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||||
* axis is invisible on screen: two raw colours that look different — a navy and
|
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||||
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||||
* paints as the same grey. Judging distinctness in raw space, as before, left
|
* space, as before, left near-identical painted swatches and stranded the
|
||||||
* near-identical painted swatches and stranded the neutrals as a run of
|
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||||
* look-alike tints at the end of the grid.
|
|
||||||
*
|
*
|
||||||
* Three steps, all in painted space:
|
* Three steps, all in painted space:
|
||||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||||
* hue, and all the neutrals together.
|
* hue, and all the neutrals together.
|
||||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||||
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||||
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||||
* distinct colours: most vivid first, a colour is kept only when at least
|
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||||
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
* colour already kept. Small palettes are already curated by their adapter
|
||||||
* palettes are already curated by their adapter and pass through whole.
|
* and pass through whole.
|
||||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||||
* seam lands in dead space and no hue family is torn across both ends.
|
* seam lands in dead space and no hue family is torn across both ends.
|
||||||
@@ -42,12 +45,12 @@ import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
|||||||
*/
|
*/
|
||||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
val painted = sortedBy { it.key }
|
val painted = sortedBy { it.key }
|
||||||
.distinctBy { paintedArgb(it.argb) }
|
.distinctBy { pastelArgb(it.argb) }
|
||||||
.map { it to oklchOf(paintedArgb(it.argb)) }
|
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||||
painted
|
painted
|
||||||
} else {
|
} else {
|
||||||
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||||
}
|
}
|
||||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
}
|
}
|
||||||
@@ -58,17 +61,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
|||||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||||
*/
|
*/
|
||||||
private fun orderAroundWheel(
|
private fun orderAroundWheel(
|
||||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
swatches: List<Pair<EventColorOption, Lab>>,
|
||||||
): List<Pair<EventColorOption, Oklch>> {
|
): List<Pair<EventColorOption, Lab>> {
|
||||||
if (swatches.size < 2) return swatches
|
if (swatches.size < 2) return swatches
|
||||||
val byHue = swatches.sortedWith(
|
val byHue = swatches.sortedWith(
|
||||||
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
|
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||||
)
|
)
|
||||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||||
var cutAfter = byHue.lastIndex
|
var cutAfter = byHue.lastIndex
|
||||||
var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
|
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||||
for (i in 0 until byHue.lastIndex) {
|
for (i in 0 until byHue.lastIndex) {
|
||||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||||
if (gap > widestGap) {
|
if (gap > widestGap) {
|
||||||
@@ -81,32 +84,102 @@ private fun orderAroundWheel(
|
|||||||
|
|
||||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
private fun thin(
|
private fun thin(
|
||||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
swatches: List<Pair<EventColorOption, Lab>>,
|
||||||
): List<Pair<EventColorOption, Oklch>> {
|
): List<Pair<EventColorOption, Lab>> {
|
||||||
val byVividness = swatches
|
val byVividness = swatches
|
||||||
.sortedWith(
|
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||||
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||||
.thenBy { it.first.key },
|
|
||||||
)
|
|
||||||
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
|
||||||
for (candidate in byVividness) {
|
for (candidate in byVividness) {
|
||||||
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||||
}
|
}
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The colour the picker paints for [argb]; the light theme stands in as the
|
/**
|
||||||
* reference, since a harmonised container differs only in lightness by theme
|
* The softening the colour picker paints over every swatch: keep the hue, scale
|
||||||
* and curation compares hue and chroma. */
|
* and clamp saturation into a gentle band, and pin value to a constant so
|
||||||
private fun paintedArgb(argb: Int): Int =
|
* nothing screams and everything reads on the surface. Value is fixed here so
|
||||||
eventTone(argb, dark = false, harmonise = true).container
|
* curation is theme-independent — only hue and saturation distinguish painted
|
||||||
|
* swatches.
|
||||||
|
*
|
||||||
|
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
||||||
|
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
||||||
|
* rather than theme-picked. Curation must reason about the colour the picker
|
||||||
|
* paints, so the two shapings have to agree: if floret's saturation band or
|
||||||
|
* curve changes, update this in step.
|
||||||
|
*/
|
||||||
|
fun pastelArgb(rawArgb: Int): Int {
|
||||||
|
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||||
|
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||||
|
val b = (rawArgb and 0xFF) / 255f
|
||||||
|
val max = maxOf(r, g, b)
|
||||||
|
val min = minOf(r, g, b)
|
||||||
|
val delta = max - min
|
||||||
|
val hue = when {
|
||||||
|
delta == 0f -> 0f
|
||||||
|
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||||
|
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||||
|
else -> 60f * (((r - g) / delta) + 4f)
|
||||||
|
}.let { if (it < 0f) it + 360f else it }
|
||||||
|
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||||
|
val s = sat.coerceIn(0.25f, 0.65f)
|
||||||
|
val v = PASTEL_VALUE
|
||||||
|
val c = v * s
|
||||||
|
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||||
|
val m = v - c
|
||||||
|
val (rr, gg, bb) = when {
|
||||||
|
hue < 60f -> Triple(c, x, 0f)
|
||||||
|
hue < 120f -> Triple(x, c, 0f)
|
||||||
|
hue < 180f -> Triple(0f, c, x)
|
||||||
|
hue < 240f -> Triple(0f, x, c)
|
||||||
|
hue < 300f -> Triple(x, 0f, c)
|
||||||
|
else -> Triple(c, 0f, x)
|
||||||
|
}
|
||||||
|
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||||
|
private const val PASTEL_VALUE = 0.82f
|
||||||
|
|
||||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||||
private const val CURATION_TRIGGER_SIZE = 36
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
|
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||||
|
private const val MIN_DELTA_E = 13.0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||||
* share one lightness, so this is really a hue/chroma separation — far enough
|
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||||
* apart that two swatches never read as the same colour in the grid.
|
* is dropped rather than shown as pale filler.
|
||||||
*/
|
*/
|
||||||
private const val MIN_DISTANCE = 0.025f
|
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||||
|
|
||||||
|
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||||
|
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||||
|
val chroma: Double get() = hypot(a, b)
|
||||||
|
|
||||||
|
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||||
|
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||||
|
|
||||||
|
fun deltaE(other: Lab): Double =
|
||||||
|
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(argb: Int): Lab {
|
||||||
|
fun linear(shift: Int): Double {
|
||||||
|
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||||
|
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||||
|
}
|
||||||
|
val r = linear(16)
|
||||||
|
val g = linear(8)
|
||||||
|
val b = linear(0)
|
||||||
|
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||||
|
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||||
|
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||||
|
val fy = f(y)
|
||||||
|
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.number
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** An event with the free text the search may have matched it on. */
|
|
||||||
data class SearchCandidate(
|
|
||||||
val event: EventInstance,
|
|
||||||
val description: String? = null,
|
|
||||||
/** Null when the event has none: [EventInstance.title] is then a display
|
|
||||||
* placeholder, which the query must not catch on. */
|
|
||||||
val title: String? = event.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A matched run inside one of a hit's texts, for highlighting. */
|
|
||||||
data class MatchSpan(val start: Int, val end: Int)
|
|
||||||
|
|
||||||
/** The part of a description a token matched, elided to what fits a row. */
|
|
||||||
data class DescriptionSnippet(
|
|
||||||
val text: String,
|
|
||||||
val spans: List<MatchSpan>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One result: the event, plus where the query matched it. */
|
|
||||||
data class SearchHit(
|
|
||||||
val event: EventInstance,
|
|
||||||
val titleSpans: List<MatchSpan> = emptyList(),
|
|
||||||
val locationSpans: List<MatchSpan> = emptyList(),
|
|
||||||
val descriptionSnippet: DescriptionSnippet? = null,
|
|
||||||
/** Already over, so the row can say so. Finished-today counts. */
|
|
||||||
val isPast: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One month's worth of hits, as the results list draws them under a header. */
|
|
||||||
data class SearchMonth(
|
|
||||||
val year: Int,
|
|
||||||
/** 1–12, so the UI can build the month's first day for a localized label. */
|
|
||||||
val monthNumber: Int,
|
|
||||||
val hits: List<SearchHit>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A finished search: a calendar of hits, plus the description-only ones. */
|
|
||||||
data class SearchResults(
|
|
||||||
val months: List<SearchMonth>,
|
|
||||||
val inDescriptions: List<SearchHit>,
|
|
||||||
) {
|
|
||||||
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
|
||||||
|
|
||||||
/** Every hit, whichever section it sits in — for select-all and delete. */
|
|
||||||
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turning a typed query into the results list.
|
|
||||||
*
|
|
||||||
* An event has to carry *every* whitespace-separated token, each in any of
|
|
||||||
* title / location / description; case is folded in Kotlin rather than by SQL's
|
|
||||||
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
|
||||||
*
|
|
||||||
* Results are a calendar, not a ranking: months from the current one, then
|
|
||||||
* forwards, then backwards, and inside a month what is still to come before
|
|
||||||
* what has passed. The day is the boundary, not the moment. A hit the query
|
|
||||||
* only reached through its description is held back in
|
|
||||||
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
|
||||||
*/
|
|
||||||
object EventSearch {
|
|
||||||
|
|
||||||
/** Characters of description kept around the match in a row's snippet. */
|
|
||||||
private const val SNIPPET_LENGTH = 96
|
|
||||||
|
|
||||||
/** Characters of lead-in shown before the match, when there is room. */
|
|
||||||
private const val SNIPPET_LEAD = 24
|
|
||||||
|
|
||||||
private val WHITESPACE = Regex("\\s+")
|
|
||||||
|
|
||||||
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
|
||||||
fun tokenize(query: String): List<String> =
|
|
||||||
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search [candidates] for [query], dropping any that don't carry every token.
|
|
||||||
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
|
||||||
* [zone] places each event on the calendar through the span rule, so an
|
|
||||||
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
|
||||||
*/
|
|
||||||
fun search(
|
|
||||||
candidates: List<SearchCandidate>,
|
|
||||||
query: String,
|
|
||||||
now: Instant,
|
|
||||||
todayStart: Instant,
|
|
||||||
zone: TimeZone,
|
|
||||||
): SearchResults {
|
|
||||||
val tokens = tokenize(query)
|
|
||||||
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
|
||||||
|
|
||||||
val today = todayStart.toLocalDateTime(zone).date
|
|
||||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
|
||||||
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
|
||||||
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
|
||||||
.thenComparator { a, b ->
|
|
||||||
// What is left reads forwards; what is behind us reads backwards
|
|
||||||
// from today, nearest first.
|
|
||||||
if (upcoming(a)) {
|
|
||||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
|
||||||
} else {
|
|
||||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
|
||||||
return SearchResults(
|
|
||||||
months = groupByMonth(dated, zone, todayStart),
|
|
||||||
inDescriptions = inDescriptions.map { it.hit },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Months from the current one, then forwards, then backwards. */
|
|
||||||
private fun groupByMonth(
|
|
||||||
dated: List<Matched>,
|
|
||||||
zone: TimeZone,
|
|
||||||
todayStart: Instant,
|
|
||||||
): List<SearchMonth> {
|
|
||||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
|
||||||
return dated
|
|
||||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
|
||||||
.entries
|
|
||||||
.sortedWith(
|
|
||||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
|
||||||
when {
|
|
||||||
it.key == currentKey -> 0
|
|
||||||
it.key > currentKey -> 1
|
|
||||||
else -> 2
|
|
||||||
}
|
|
||||||
}.thenComparator { a, b ->
|
|
||||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map { (key, entries) ->
|
|
||||||
SearchMonth(
|
|
||||||
year = key / 12,
|
|
||||||
monthNumber = key % 12 + 1,
|
|
||||||
hits = entries.map { it.hit },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Year and month as one comparable number. */
|
|
||||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
|
||||||
|
|
||||||
private fun match(
|
|
||||||
candidate: SearchCandidate,
|
|
||||||
tokens: List<String>,
|
|
||||||
now: Instant,
|
|
||||||
zone: TimeZone,
|
|
||||||
): Matched? {
|
|
||||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
|
||||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
|
||||||
// Collapsed so a multi-line description matches the way it is drawn.
|
|
||||||
val description = candidate.description
|
|
||||||
?.replace(WHITESPACE, " ")
|
|
||||||
?.trim()
|
|
||||||
?.takeIf { it.isNotEmpty() }
|
|
||||||
|
|
||||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
|
||||||
val present = { token: String ->
|
|
||||||
title?.contains(token, ignoreCase = true) == true ||
|
|
||||||
location?.contains(token, ignoreCase = true) == true ||
|
|
||||||
description?.contains(token, ignoreCase = true) == true
|
|
||||||
}
|
|
||||||
if (!tokens.all(present)) return null
|
|
||||||
|
|
||||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
|
||||||
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
|
||||||
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
|
||||||
return Matched(
|
|
||||||
hit = SearchHit(
|
|
||||||
event = candidate.event,
|
|
||||||
titleSpans = titleSpans,
|
|
||||||
locationSpans = locationSpans,
|
|
||||||
descriptionSnippet = description
|
|
||||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
|
||||||
?.let { snippet(it, descriptionSpans) },
|
|
||||||
isPast = candidate.event.dayEnd(zone) < now,
|
|
||||||
),
|
|
||||||
// Nothing in the name or the place caught the query: it is only here
|
|
||||||
// for something in its notes.
|
|
||||||
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every occurrence of every token in [text], merged where they overlap. */
|
|
||||||
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
|
||||||
val raw = mutableListOf<MatchSpan>()
|
|
||||||
for (token in tokens) {
|
|
||||||
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
|
||||||
while (index >= 0) {
|
|
||||||
raw += MatchSpan(index, index + token.length)
|
|
||||||
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (raw.isEmpty()) return emptyList()
|
|
||||||
val sorted = raw.sortedBy { it.start }
|
|
||||||
val merged = mutableListOf(sorted.first())
|
|
||||||
for (span in sorted.drop(1)) {
|
|
||||||
val last = merged.last()
|
|
||||||
if (span.start <= last.end) {
|
|
||||||
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
|
||||||
} else {
|
|
||||||
merged += span
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A window of [description] around its first match, elided at both ends,
|
|
||||||
* with the spans rebased onto it and any falling outside dropped.
|
|
||||||
*/
|
|
||||||
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
|
||||||
if (description.length <= SNIPPET_LENGTH) {
|
|
||||||
return DescriptionSnippet(description, spans)
|
|
||||||
}
|
|
||||||
val first = spans.first().start
|
|
||||||
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
|
||||||
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
|
||||||
val prefix = if (start > 0) "…" else ""
|
|
||||||
val suffix = if (end < description.length) "…" else ""
|
|
||||||
val text = prefix + description.substring(start, end) + suffix
|
|
||||||
val shift = prefix.length - start
|
|
||||||
val rebased = spans
|
|
||||||
.filter { it.start >= start && it.end <= end }
|
|
||||||
.map { MatchSpan(it.start + shift, it.end + shift) }
|
|
||||||
return DescriptionSnippet(text, rebased)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
|
||||||
val event: EventInstance get() = hit.event
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The event's first day as an instant in [zone]. An all-day event sits at
|
|
||||||
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
|
|
||||||
*/
|
|
||||||
private fun EventInstance.dayStart(zone: TimeZone): Instant =
|
|
||||||
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
|
||||||
|
|
||||||
/** The moment the event's last day is over in [zone], exclusive. */
|
|
||||||
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
|
|
||||||
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The zone this form's wall-clock times mean, matching what the data layer
|
|
||||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
|
||||||
*/
|
|
||||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
|
||||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
|
||||||
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
|
||||||
* wall clock instead would rewrite the series' length across a DST boundary.
|
|
||||||
*
|
|
||||||
* All-day events are date-anchored — use [shiftedByDays]; this returns them
|
|
||||||
* untouched.
|
|
||||||
*/
|
|
||||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
|
||||||
if (isAllDay) return this
|
|
||||||
val zone = resolvedZone(deviceZone)
|
|
||||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
|
||||||
return copy(
|
|
||||||
start = newStart.toLocalDateTime(zone),
|
|
||||||
end = (newStart + span).toLocalDateTime(zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The form moved [days] calendar days, keeping its time of day and its span.
|
|
||||||
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
|
||||||
* duration, for the same reason [shiftedTo] does.
|
|
||||||
*/
|
|
||||||
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
|
||||||
if (days == 0) return this
|
|
||||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
|
||||||
if (isAllDay) {
|
|
||||||
return copy(
|
|
||||||
start = newStart,
|
|
||||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val zone = resolvedZone(deviceZone)
|
|
||||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
|
||||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
|
||||||
}
|
|
||||||
@@ -15,9 +15,7 @@ import kotlin.time.Instant
|
|||||||
* calendar this way to create a new event, passing the fields as
|
* calendar this way to create a new event, passing the fields as
|
||||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||||
* next full hour, and a placeholder hour for a missing end that the form then
|
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||||
* stretches to the default-duration setting (the intent is opened as
|
|
||||||
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
|
|
||||||
* it resolves to the last-used / first-writable calendar, exactly like the
|
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||||
* `.ics` single-event and plain new-event paths.
|
* `.ics` single-event and plain new-event paths.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -62,11 +62,6 @@ data class EventInstance(
|
|||||||
val isAllDay: Boolean,
|
val isAllDay: Boolean,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val location: String?,
|
val location: String?,
|
||||||
/**
|
|
||||||
* Only search results, which read the series master, fill this in — the
|
|
||||||
* Instances query already yields one row per occurrence.
|
|
||||||
*/
|
|
||||||
val isRecurring: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,12 +124,6 @@ data class EventDetail(
|
|||||||
val eventColor: Int? = null,
|
val eventColor: Int? = null,
|
||||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||||
val eventColorKey: String? = null,
|
val eventColorKey: String? = null,
|
||||||
/**
|
|
||||||
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
|
||||||
* set) rather than a master, so a reschedule takes the plain whole-row path
|
|
||||||
* whatever [rrule] a sync adapter left on it.
|
|
||||||
*/
|
|
||||||
val isException: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||||
DayOfWeek.MONDAY to "MO",
|
DayOfWeek.MONDAY to "MO",
|
||||||
DayOfWeek.TUESDAY to "TU",
|
DayOfWeek.TUESDAY to "TU",
|
||||||
DayOfWeek.WEDNESDAY to "WE",
|
DayOfWeek.WEDNESDAY to "WE",
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
|
||||||
*
|
|
||||||
* `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO`
|
|
||||||
* would keep naming Monday after the anchor became a Wednesday and the series
|
|
||||||
* would not move at all — `BYDAY` is re-derived from [newStart].
|
|
||||||
*
|
|
||||||
* Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has
|
|
||||||
* to agree with the series *anchor*, and weekday arithmetic is the only kind
|
|
||||||
* that survives the same wall-clock shift unchanged. Everything else returns
|
|
||||||
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
|
||||||
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so
|
|
||||||
* anything realignable is also a rule [problems] can check the `UNTIL` of.
|
|
||||||
*/
|
|
||||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
|
||||||
if (oldStart == newStart) return rrule
|
|
||||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
|
||||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
|
||||||
if (parts.isEmpty()) return null
|
|
||||||
var weekly = false
|
|
||||||
val rebuilt = parts.map { part ->
|
|
||||||
val eq = part.indexOf('=')
|
|
||||||
if (eq <= 0) return null
|
|
||||||
val key = part.substring(0, eq).uppercase()
|
|
||||||
val value = part.substring(eq + 1).trim()
|
|
||||||
when (key) {
|
|
||||||
"FREQ" -> {
|
|
||||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
|
||||||
part
|
|
||||||
}
|
|
||||||
"BYDAY" -> {
|
|
||||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
|
||||||
if (!value.equals(old, ignoreCase = true)) return null
|
|
||||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
|
||||||
}
|
|
||||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
|
||||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
|
||||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
|
||||||
return prefix + rebuilt.joinToString(";")
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.color
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The colours one calendar's identity resolves to in one theme.
|
|
||||||
*
|
|
||||||
* The app paints a calendar's colour in two structurally different places, and
|
|
||||||
* they want opposite things:
|
|
||||||
*
|
|
||||||
* - a **[container]** sits behind text (week and day blocks, month bars, widget
|
|
||||||
* rows, picker swatches), so it has to contrast with its own ink;
|
|
||||||
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
|
|
||||||
* search stripes, calendar icon tints, the detail header), so it has to
|
|
||||||
* contrast with the *surface* instead.
|
|
||||||
*
|
|
||||||
* Painting both from one colour is what made this hard to get right: deep enough
|
|
||||||
* to carry white text is too dark to see as a dot on a dark surface, and light
|
|
||||||
* enough to show up there is too pale to carry white text. Splitting them lets
|
|
||||||
* each be pinned to the lightness its job needs, while hue and chroma — the parts
|
|
||||||
* that actually say *which calendar this is* — stay shared, so the two roles
|
|
||||||
* still read as the same colour.
|
|
||||||
*/
|
|
||||||
data class EventTone(
|
|
||||||
val container: Int,
|
|
||||||
val onContainer: Int,
|
|
||||||
val accent: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve [rawArgb] for the current theme.
|
|
||||||
*
|
|
||||||
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
|
|
||||||
* role, which is what makes the ink predictable: every container lands at the
|
|
||||||
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
|
|
||||||
* it off the provider's colour is painted verbatim — the sync source's own look,
|
|
||||||
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
|
|
||||||
* nothing constrains what the provider sends.
|
|
||||||
*/
|
|
||||||
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
|
|
||||||
val opaque = rawArgb or 0xFF000000.toInt()
|
|
||||||
val raw = oklchOf(opaque)
|
|
||||||
if (!harmonise) {
|
|
||||||
return EventTone(
|
|
||||||
container = opaque,
|
|
||||||
onContainer = inkFor(raw.lightness),
|
|
||||||
accent = opaque,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// A near-grey source has no hue worth keeping, so it stays grey rather than
|
|
||||||
// being pushed to an arbitrary one; everything else is held inside a band
|
|
||||||
// that keeps calendars apart without going neon.
|
|
||||||
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
|
|
||||||
// Two poles rather than one. Forcing every hue deep is what turned the warm,
|
|
||||||
// naturally light ones muddy — a dark orange is brown and a dark yellow is
|
|
||||||
// olive, which is a fact about those hues, not a tuning miss. So a colour is
|
|
||||||
// sent to whichever pole it already sits nearer: most land deep and carry
|
|
||||||
// white ink, while genuinely light sources (yellows, creams, pale tints)
|
|
||||||
// stay light and carry dark ink, keeping the character that made them
|
|
||||||
// recognisable. Either way the colour is pulled clear of the middle, where
|
|
||||||
// neither ink reads well — which was the original fault.
|
|
||||||
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
|
|
||||||
val containerLightness = when {
|
|
||||||
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
|
|
||||||
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
|
|
||||||
dark -> CONTAINER_LIGHTNESS_DARK
|
|
||||||
else -> CONTAINER_LIGHTNESS_LIGHT
|
|
||||||
}
|
|
||||||
// The accent takes no pole: it is a mark on the app's surface, so it has to
|
|
||||||
// contrast with that surface whichever way its container went.
|
|
||||||
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
|
|
||||||
return EventTone(
|
|
||||||
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
|
|
||||||
onContainer = inkFor(containerLightness),
|
|
||||||
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* White or black ink for a background of the given perceptual [lightness].
|
|
||||||
*
|
|
||||||
* Derived rather than hardcoded so it stays right for raw provider colours,
|
|
||||||
* where lightness is whatever the sync source sent. For harmonised containers it
|
|
||||||
* is constant by construction — that is the point of pinning the lightness.
|
|
||||||
*/
|
|
||||||
fun inkFor(lightness: Float): Int =
|
|
||||||
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightness at which white ink overtakes black. Sits above the midpoint because
|
|
||||||
* lightness is perceptual: a colour has to be distinctly light before black wins.
|
|
||||||
*/
|
|
||||||
const val INK_FLIP_LIGHTNESS = 0.62f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw lightness at or above which a colour keeps its light character instead of
|
|
||||||
* being pushed deep. Set high enough that oranges and warm reds still go deep —
|
|
||||||
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
|
|
||||||
* so only the genuinely pale sources take the light pole.
|
|
||||||
*/
|
|
||||||
const val LIGHT_POLE_THRESHOLD = 0.72f
|
|
||||||
|
|
||||||
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
|
|
||||||
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
|
|
||||||
|
|
||||||
/** Same in dark mode, a shade lighter so a block separates from the surface. */
|
|
||||||
const val CONTAINER_LIGHTNESS_DARK = 0.48f
|
|
||||||
|
|
||||||
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
|
|
||||||
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
|
|
||||||
|
|
||||||
/** Same in dark mode, held down a little so a pale block doesn't glare. */
|
|
||||||
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
|
|
||||||
|
|
||||||
/** Accent lightness: dark enough to read as a mark on a pale surface. */
|
|
||||||
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
|
|
||||||
|
|
||||||
/** Same against a dark surface, where the mark has to be the light one. */
|
|
||||||
const val ACCENT_LIGHTNESS_DARK = 0.78f
|
|
||||||
|
|
||||||
/** Below this chroma a colour counts as grey and keeps no hue. */
|
|
||||||
const val GREY_CHROMA = 0.02f
|
|
||||||
|
|
||||||
/** Chroma band for harmonised colours: distinct, but never electric. */
|
|
||||||
const val MIN_CHROMA = 0.07f
|
|
||||||
const val MAX_CHROMA = 0.16f
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.color
|
|
||||||
|
|
||||||
import kotlin.math.atan2
|
|
||||||
import kotlin.math.cbrt
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.hypot
|
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.sin
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
|
|
||||||
* (colourfulness) and [hue] in degrees.
|
|
||||||
*
|
|
||||||
* The point of using it over HSV is that its lightness axis matches what the eye
|
|
||||||
* calls brightness. HSV's "value" does not: at a single pinned value the hues
|
|
||||||
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
|
|
||||||
* pinning value produced fills that needed different ink per hue. Pin Oklch
|
|
||||||
* lightness instead and every hue lands at the same apparent brightness, so one
|
|
||||||
* ink serves all of them.
|
|
||||||
*
|
|
||||||
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
|
|
||||||
* the sRGB limit.
|
|
||||||
*/
|
|
||||||
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
|
|
||||||
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
|
|
||||||
* saturated blues).
|
|
||||||
*/
|
|
||||||
fun distanceTo(other: Oklch): Float {
|
|
||||||
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
|
|
||||||
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
|
|
||||||
val dl = (lightness - other.lightness).toDouble()
|
|
||||||
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read [argb]'s opaque colour as Oklch. */
|
|
||||||
fun oklchOf(argb: Int): Oklch {
|
|
||||||
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
|
|
||||||
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
|
|
||||||
val b = toLinear((argb and 0xFF) / 255.0)
|
|
||||||
|
|
||||||
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
|
||||||
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
|
||||||
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
|
||||||
|
|
||||||
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
|
||||||
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
|
||||||
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
|
||||||
|
|
||||||
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
|
|
||||||
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
|
|
||||||
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
|
|
||||||
* and gives up chroma until it does. Holding lightness is what matters here —
|
|
||||||
* it's the axis the contrast guarantees rest on.
|
|
||||||
*/
|
|
||||||
fun Oklch.toArgb(): Int {
|
|
||||||
val fitted = if (inSrgb(lightness, chroma, hue)) {
|
|
||||||
chroma
|
|
||||||
} else {
|
|
||||||
var low = 0f
|
|
||||||
var high = chroma
|
|
||||||
repeat(GAMUT_STEPS) {
|
|
||||||
val mid = (low + high) / 2f
|
|
||||||
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
|
|
||||||
}
|
|
||||||
low
|
|
||||||
}
|
|
||||||
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
|
|
||||||
return 0xFF shl 24 or
|
|
||||||
(channel(r) shl 16) or
|
|
||||||
(channel(g) shl 8) or
|
|
||||||
channel(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
|
|
||||||
val a = chroma * cosDeg(hue)
|
|
||||||
val b = chroma * sinDeg(hue)
|
|
||||||
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
|
|
||||||
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
|
|
||||||
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
|
|
||||||
return Triple(
|
|
||||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
||||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
||||||
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
|
|
||||||
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
|
|
||||||
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
|
||||||
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
|
||||||
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun channel(linear: Double): Int =
|
|
||||||
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
|
|
||||||
|
|
||||||
private fun toLinear(c: Double): Double =
|
|
||||||
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
|
||||||
|
|
||||||
private fun toSrgb(c: Double): Double =
|
|
||||||
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
|
|
||||||
|
|
||||||
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
|
|
||||||
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
|
|
||||||
|
|
||||||
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
|
|
||||||
private const val GAMUT_STEPS = 24
|
|
||||||
|
|
||||||
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
|
|
||||||
private const val GAMUT_EPSILON = 1e-4
|
|
||||||
@@ -10,7 +10,6 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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
|
||||||
@@ -28,10 +27,6 @@ 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
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
@@ -80,7 +75,6 @@ fun CalendarHost(
|
|||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
requestedInsertSource: ImportSource = ImportSource.Insert,
|
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
@@ -209,7 +203,7 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
importFormSource = requestedInsertSource
|
importFormSource = ImportSource.Insert
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -299,33 +293,6 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
|
||||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
|
||||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
|
||||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
|
||||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
|
||||||
val key = longArrayOf(
|
|
||||||
event.eventId,
|
|
||||||
event.start.toEpochMilliseconds(),
|
|
||||||
event.end.toEpochMilliseconds(),
|
|
||||||
)
|
|
||||||
heldEditKey = key
|
|
||||||
editKey = key
|
|
||||||
}
|
|
||||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
|
||||||
// register no drag, while the edit form and its TalkBack action stay put.
|
|
||||||
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
|
||||||
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
|
|
||||||
EventMoveScope(
|
|
||||||
movableCalendarIds = movableCalendarIds,
|
|
||||||
dragEnabled = dragToReschedule,
|
|
||||||
move = reschedule::move,
|
|
||||||
inFlight = reschedule.inFlight,
|
|
||||||
undoStarted = reschedule.undoStarted,
|
|
||||||
edit = onEditEvent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||||
@@ -343,7 +310,6 @@ fun CalendarHost(
|
|||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -400,11 +366,6 @@ fun CalendarHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
|
||||||
// right after the calendar views so later overlays cover it.
|
|
||||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
|
||||||
|
|
||||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
// detail screen draws on top, and closing it returns to the results.
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
|
|||||||
@@ -53,18 +53,4 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether events may be dragged to another slot to reschedule them (#68, #173).
|
|
||||||
* Off until the stored value arrives, even though the preference itself defaults
|
|
||||||
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
|
|
||||||
* start takes to read DataStore, but re-arms the one thing a disabled user
|
|
||||||
* switched off to be rid of.
|
|
||||||
*/
|
|
||||||
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ package de.jeanlucmakiola.calendula.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
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
|
||||||
@@ -25,22 +22,13 @@ 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.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
|
|
||||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
|
||||||
private sealed interface RootTarget {
|
|
||||||
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
|
||||||
data object Loading : RootTarget
|
|
||||||
data object App : RootTarget
|
|
||||||
data class Step(val step: OnboardingStep) : RootTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(
|
fun RootScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -51,8 +39,6 @@ fun RootScreen(
|
|||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
|
||||||
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
@@ -88,87 +74,57 @@ fun RootScreen(
|
|||||||
onDispose { lifecycle.removeObserver(obs) }
|
onDispose { lifecycle.removeObserver(obs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val onboarding: OnboardingViewModel = hiltViewModel()
|
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||||
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||||
// The grant is reported however it was taken, including via Android's
|
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||||
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
// hangs off that, not off the in-app callback.
|
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||||
LaunchedEffect(hasPermission) {
|
if (granted) {
|
||||||
onboarding.setHasPermission(hasPermission)
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
// Null until DataStore's first emission — render nothing for that
|
||||||
}
|
// frame instead of flashing the wrong screen.
|
||||||
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
// Arms the one-time explainer for the switch to the device's own calendar
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
// visibility (#75), which the wizard then carries as a step of its own.
|
// One-time explainer for the switch to the device's own calendar
|
||||||
LaunchedEffect(hasPermission) {
|
// visibility (#75), armed by the reconciler.
|
||||||
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||||
}
|
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||||
|
// Runs on entry however the permission was granted, including via
|
||||||
val current = plan
|
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||||
val target: RootTarget = when {
|
LaunchedEffect(Unit) {
|
||||||
current == null -> RootTarget.Loading
|
visibilityNotice.reconcile()
|
||||||
current.current == null -> RootTarget.App
|
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||||
else -> RootTarget.Step(current.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which way the flow is moving, so a step-back slides back. The app sorts
|
|
||||||
// after every step, so finishing the wizard reads as one more move forward.
|
|
||||||
val ordinal = when (target) {
|
|
||||||
RootTarget.Loading -> Int.MIN_VALUE
|
|
||||||
RootTarget.App -> Int.MAX_VALUE
|
|
||||||
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
|
||||||
}
|
|
||||||
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
|
||||||
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
|
||||||
SideEffect { lastOrdinal = ordinal }
|
|
||||||
|
|
||||||
val slideSpec = rememberOnboardingSlideSpec()
|
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = target,
|
|
||||||
transitionSpec = {
|
|
||||||
// Coming off the blank first frame is not a step change.
|
|
||||||
if (initialState == RootTarget.Loading) {
|
|
||||||
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
|
||||||
} else {
|
|
||||||
onboardingSlideTransition(
|
|
||||||
slideDir = slideDir,
|
|
||||||
spec = slideSpec,
|
|
||||||
fadeSpec = fadeSpec,
|
|
||||||
reduceMotion = reduceMotion,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
if (onboardingDone == true && noticePending) {
|
||||||
label = "onboardingGate",
|
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||||
) { shown ->
|
}
|
||||||
when (shown) {
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
RootTarget.Loading -> Unit
|
when (done) {
|
||||||
RootTarget.App -> CalendarHost(
|
true -> CalendarHost(
|
||||||
|
modifier = modifier,
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
widgetNavRequest = widgetNavRequest,
|
||||||
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = onImportConsumed,
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = onInsertConsumed,
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
|
)
|
||||||
|
false -> ReminderOnboardingScreen(
|
||||||
|
onFinished = reminderOnboarding::finish,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PermissionScreen(
|
||||||
|
onGranted = { hasPermission = true },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
requestedDetailKey = requestedDetailKey,
|
|
||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
|
||||||
widgetNavRequest = widgetNavRequest,
|
|
||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
|
||||||
requestedImportUri = requestedImportUri,
|
|
||||||
onImportConsumed = onImportConsumed,
|
|
||||||
requestedInsertForm = requestedInsertForm,
|
|
||||||
requestedInsertSource = requestedInsertSource,
|
|
||||||
onInsertConsumed = onInsertConsumed,
|
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
|
||||||
)
|
)
|
||||||
// Both halves of a transition read their position out of the live
|
|
||||||
// plan; the one the outgoing step was drawn with is already gone.
|
|
||||||
is RootTarget.Step -> current?.let { plan ->
|
|
||||||
OnboardingSteps(
|
|
||||||
step = shown.step,
|
|
||||||
plan = plan,
|
|
||||||
viewModel = onboarding,
|
|
||||||
onPermissionGranted = { hasPermission = true },
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
@@ -138,7 +137,7 @@ internal fun AgendaEventRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(eventAccent(event.color, dark, soften)),
|
.background(eventFill(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ fun AgendaScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = anchor,
|
currentDate = anchor,
|
||||||
drawerState = drawerState,
|
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -135,7 +134,6 @@ fun AgendaScreen(
|
|||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
@@ -277,7 +275,7 @@ private fun AgendaRangeBanner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
showToday: Boolean,
|
showToday: Boolean,
|
||||||
@@ -410,7 +408,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
private fun AgendaTopBar(
|
private fun AgendaTopBar(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
@@ -442,7 +439,6 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/** How far ahead the agenda preview's stand-in window runs. */
|
|
||||||
private const val PREVIEW_WINDOW_DAYS = 14
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
|
||||||
* the day headers are real; only the events are stand-ins.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun AgendaViewPreview(
|
|
||||||
height: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
|
||||||
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
|
||||||
|
|
||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
|
||||||
AgendaContent(
|
|
||||||
state = state,
|
|
||||||
// Stand-in events are all upcoming, so the past-event preference
|
|
||||||
// cannot change what the preview shows — keep it at the default.
|
|
||||||
pastDisplay = PastEventDisplay.SHOW,
|
|
||||||
showToday = true,
|
|
||||||
onRetry = {},
|
|
||||||
onEventClick = {},
|
|
||||||
onOpenDay = {},
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sample agenda state, grouped through the same helper the live view uses. */
|
|
||||||
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
|
||||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
|
||||||
return AgendaUiState.Success(
|
|
||||||
anchor = today,
|
|
||||||
today = today,
|
|
||||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
|
||||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
|
||||||
rangeIsOverride = false,
|
|
||||||
rangeEnd = windowEnd,
|
|
||||||
// The range bar belongs to the screen, not the list — the preview shows
|
|
||||||
// the list only.
|
|
||||||
showRangeBar = false,
|
|
||||||
zone = zone,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -88,7 +88,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
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.eventAccent
|
|
||||||
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
|
||||||
@@ -443,7 +442,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.snap
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A timed block's own time label, crossfaded rather than replaced — the block
|
|
||||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
|
||||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
|
||||||
snap()
|
|
||||||
} else {
|
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
|
||||||
}
|
|
||||||
Crossfade(
|
|
||||||
targetState = label,
|
|
||||||
animationSpec = spec,
|
|
||||||
label = "block-time",
|
|
||||||
modifier = modifier,
|
|
||||||
) { text ->
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = color,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where a timed block sits in its column, after tweening. */
|
|
||||||
@Immutable
|
|
||||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
|
||||||
* the caller keying each block by identity; a block composed for the first time
|
|
||||||
* starts at its target, so nothing flies in on the first frame.
|
|
||||||
*
|
|
||||||
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
|
||||||
* grid lines follow it instantly — so the tween stands down for the gesture
|
|
||||||
* rather than leaving the blocks trailing the ruler they are measured against.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
|
||||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
|
||||||
LocalTimelineZoom.current.isPinching
|
|
||||||
) {
|
|
||||||
snap()
|
|
||||||
} else {
|
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
|
||||||
}
|
|
||||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
|
||||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
|
||||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
|
||||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
|
||||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.CalendarMonth,
|
Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = eventAccent(color, dark, soften),
|
tint = eventFill(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -20,7 +19,6 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.DrawerState
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
@@ -29,23 +27,19 @@ import androidx.compose.runtime.Composable
|
|||||||
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.rememberCoroutineScope
|
|
||||||
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.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
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.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,8 +50,7 @@ import kotlinx.datetime.LocalDate
|
|||||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||||
* The host screen owns the drawer state; the sheet reads it only to dismiss
|
* The host screen owns the drawer state.
|
||||||
* itself on back.
|
|
||||||
*
|
*
|
||||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||||
@@ -66,17 +59,12 @@ import kotlinx.datetime.LocalDate
|
|||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
currentView: CalendarView,
|
currentView: CalendarView,
|
||||||
currentDate: LocalDate,
|
currentDate: LocalDate,
|
||||||
drawerState: DrawerState,
|
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
// Registered in the sheet so it takes precedence over the host's back handler.
|
|
||||||
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
|
|
||||||
|
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||||
@@ -95,7 +83,7 @@ fun CalendarDrawer(
|
|||||||
position = positionOf(index, viewOrder.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { DrawerLeadingIcon(view.icon) },
|
leading = { Icon(view.icon, contentDescription = null) },
|
||||||
onClick = { onSelectView(view) },
|
onClick = { onSelectView(view) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -105,7 +93,7 @@ fun CalendarDrawer(
|
|||||||
title = stringResource(R.string.drawer_jump_to_date),
|
title = stringResource(R.string.drawer_jump_to_date),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||||
onClick = { showDatePicker = true },
|
onClick = { showDatePicker = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,7 +107,7 @@ fun CalendarDrawer(
|
|||||||
title = stringResource(R.string.month_action_settings),
|
title = stringResource(R.string.month_action_settings),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||||
onClick = onSettings,
|
onClick = onSettings,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -138,27 +126,13 @@ fun CalendarDrawer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
|
||||||
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
|
||||||
@Composable
|
|
||||||
private fun DrawerLeadingIcon(icon: ImageVector) {
|
|
||||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(icon, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Branded header: the app-icon chip beside the app name. */
|
/** Branded header: the app-icon chip beside the app name. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DrawerHeader() {
|
private fun DrawerHeader() {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(
|
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 24.dp,
|
|
||||||
bottom = 16.dp,
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -190,11 +164,6 @@ private fun DrawerSectionHeader(text: String) {
|
|||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 8.dp,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top-bar title that doubles as the jump-to-date entry point: the formatted
|
|
||||||
* [title] with a drop-down caret, opening the same [CalendarDatePickerDialog] the
|
|
||||||
* drawer uses and reporting the chosen day through [onJumpToDate].
|
|
||||||
*
|
|
||||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
|
||||||
* visible day, week start or month anchor).
|
|
||||||
*
|
|
||||||
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
|
||||||
* start alignment: M3 places the title flush against the navigation icon's
|
|
||||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
|
||||||
* would swallow taps meant for the menu button.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CalendarTitleButton(
|
|
||||||
title: String,
|
|
||||||
currentDate: LocalDate,
|
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.heightIn(min = 48.dp)
|
|
||||||
.clip(MaterialTheme.shapes.large)
|
|
||||||
.clickable(
|
|
||||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
|
||||||
role = Role.Button,
|
|
||||||
) { showDatePicker = true }
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDatePicker) {
|
|
||||||
CalendarDatePickerDialog(
|
|
||||||
initial = currentDate,
|
|
||||||
onConfirm = {
|
|
||||||
showDatePicker = false
|
|
||||||
onJumpToDate(it)
|
|
||||||
},
|
|
||||||
onDismiss = { showDatePicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,8 +57,7 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
* is the subset the pill actually steps through, in [order]. The navigation
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
* drawer keeps its own separate order and always lists every view, so a view
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
* disabled here stays reachable there — including when [cycle] is emptied and
|
* disabled here stays reachable there.
|
||||||
* the pill disappears altogether.
|
|
||||||
*/
|
*/
|
||||||
data class QuickSwitchConfig(
|
data class QuickSwitchConfig(
|
||||||
val order: List<CalendarView>,
|
val order: List<CalendarView>,
|
||||||
@@ -68,15 +67,14 @@ data class QuickSwitchConfig(
|
|||||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
|
||||||
* Fewest views that keep the switch meaningful. A single target is not a
|
|
||||||
* switch, so below this the pill is hidden rather than special-cased (#150);
|
|
||||||
* the drawer still reaches every view.
|
|
||||||
*/
|
|
||||||
const val MIN_CYCLE = 2
|
|
||||||
|
|
||||||
/** All views, in default order, all enabled. */
|
/** All views, in default order, all enabled. */
|
||||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||||
|
* least two targets, so the settings screen blocks disabling below this.
|
||||||
|
*/
|
||||||
|
const val MIN_ENABLED = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,53 +2,45 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
import androidx.compose.ui.graphics.luminance
|
||||||
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
|
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||||
* per role — before display (issue #36). Provided app-wide from the setting; the
|
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||||
* default `true` is the app's own look. When off, the raw provider colour is
|
* setting; the default `true` keeps the historical look. When off, the raw
|
||||||
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
|
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||||
* apps. Widgets live outside this composition and read the preference directly,
|
* and other calendar apps. Widgets live outside this composition and read the
|
||||||
* then pass the flag through.
|
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||||
*/
|
*/
|
||||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fill for a surface that carries text on it — a week/day block, a month bar, a
|
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||||
* widget row, a picker swatch. Pair it with [eventInk].
|
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||||
|
* pastelize also returns an opaque colour).
|
||||||
*/
|
*/
|
||||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
Color(eventTone(rawArgb, dark, soften).container)
|
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
|
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
||||||
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
|
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||||
* pinned to contrast with the surface instead of with ink.
|
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||||
|
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||||
|
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||||
|
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||||
|
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||||
|
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||||
*/
|
*/
|
||||||
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
||||||
Color(eventTone(rawArgb, dark, soften).accent)
|
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
||||||
|
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
|
* Relative-luminance crossover where white text starts to out-contrast black.
|
||||||
*
|
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
||||||
* Takes the fill rather than the raw colour so it stays correct for the raw
|
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
||||||
* (un-harmonised) path too, where the provider decides the lightness.
|
|
||||||
*/
|
*/
|
||||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
|
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
||||||
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ink alpha for a block's secondary line (the time under the title). Held above
|
|
||||||
* a fainter value because harmonised containers are deep: the small time text
|
|
||||||
* needs to stay clear of the 4.5:1 WCAG asks of body text.
|
|
||||||
*/
|
|
||||||
const val SECONDARY_INK_ALPHA = 0.8f
|
|
||||||
|
|
||||||
private fun Color.toArgbInt(): Int =
|
|
||||||
(0xFF shl 24) or
|
|
||||||
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
|
|
||||||
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
|
|
||||||
(blue * 255f).toInt().coerceIn(0, 255)
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
||||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** How far the finger may wander during the hold — well under touch slop. */
|
|
||||||
private val PICKUP_TOLERANCE = 6.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick an event block up with a long press and drag it, without
|
|
||||||
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
|
||||||
* ancestor consumes or the finger leaves the block, which a
|
|
||||||
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
|
||||||
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberEventDragSource(
|
|
||||||
enabled: Boolean,
|
|
||||||
key: Any?,
|
|
||||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
|
||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
|
||||||
onDrop: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
): Modifier = rememberDragSurface(
|
|
||||||
enabled = enabled,
|
|
||||||
key = key,
|
|
||||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
|
||||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
|
||||||
true
|
|
||||||
},
|
|
||||||
onMove = onMove,
|
|
||||||
onDrop = onDrop,
|
|
||||||
onCancel = onCancel,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same pickup, for a surface that carries many draggable pieces rather than
|
|
||||||
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
|
||||||
* layer. [onPickUp] receives the press position local to this node and answers
|
|
||||||
* whether anything is there; false abandons the gesture.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberDragSurface(
|
|
||||||
enabled: Boolean,
|
|
||||||
key: Any?,
|
|
||||||
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
|
|
||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
|
||||||
onDrop: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
): Modifier {
|
|
||||||
if (!enabled) return Modifier
|
|
||||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
|
||||||
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
|
|
||||||
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
|
||||||
val currentPickUp by rememberUpdatedState(onPickUp)
|
|
||||||
val currentMove by rememberUpdatedState(onMove)
|
|
||||||
val currentDrop by rememberUpdatedState(onDrop)
|
|
||||||
val currentCancel by rememberUpdatedState(onCancel)
|
|
||||||
|
|
||||||
return Modifier
|
|
||||||
.onGloballyPositioned { coordinates[0] = it }
|
|
||||||
.pointerInput(key) {
|
|
||||||
awaitEachGesture {
|
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
|
||||||
val origin = down.position
|
|
||||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
|
||||||
while (true) {
|
|
||||||
val main = awaitPointerEvent()
|
|
||||||
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
|
||||||
if (!change.pressed) break
|
|
||||||
if (main.changes.count { it.pressed } > 1) break
|
|
||||||
if ((change.position - origin).getDistance() > tolerance) break
|
|
||||||
// An ancestor's claim only becomes visible once the whole
|
|
||||||
// main pass has run, so look again on the final pass.
|
|
||||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
|
||||||
if (final.changes.any { it.isConsumed }) break
|
|
||||||
}
|
|
||||||
} == null
|
|
||||||
if (!heldStill) return@awaitEachGesture
|
|
||||||
|
|
||||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
|
||||||
val took = currentPickUp(
|
|
||||||
down.position,
|
|
||||||
layout.localToRoot(down.position),
|
|
||||||
layout.positionInRoot(),
|
|
||||||
layout.size,
|
|
||||||
)
|
|
||||||
if (!took) return@awaitEachGesture
|
|
||||||
var dropped = false
|
|
||||||
try {
|
|
||||||
// Driven on the initial pass, which runs parent → child: an
|
|
||||||
// ancestor that outranks us (the pinch) has already consumed
|
|
||||||
// by the time we look, and descendants — the month grid's
|
|
||||||
// full-bleed tap layer — see our consumption.
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
|
||||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
|
||||||
if (change.isConsumed) break
|
|
||||||
if (!change.pressed) {
|
|
||||||
change.consume()
|
|
||||||
dropped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (event.changes.count { it.pressed } > 1) break
|
|
||||||
change.consume()
|
|
||||||
coordinates[0]?.takeIf { it.isAttached }
|
|
||||||
?.let { currentMove(it.localToRoot(change.position)) }
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Also reached when the pointer node is disposed mid-drag —
|
|
||||||
// a cancel, not a drop, and must never write.
|
|
||||||
if (dropped) currentDrop() else currentCancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
|
||||||
@Composable
|
|
||||||
fun DragSnapHaptics(slot: Any?) {
|
|
||||||
val haptics = LocalHapticFeedback.current
|
|
||||||
val previous = remember { arrayOfNulls<Any>(1) }
|
|
||||||
LaunchedEffect(slot) {
|
|
||||||
val had = previous[0]
|
|
||||||
previous[0] = slot
|
|
||||||
when {
|
|
||||||
slot == null -> Unit
|
|
||||||
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
||||||
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
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.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.floret.components.SnackChip
|
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/** How long the confirmation chip stays up, matching a short snackbar. */
|
|
||||||
private const val CHIP_MILLIS = 4_000L
|
|
||||||
|
|
||||||
/** How long an *undone* move stays up — shorter, since it offers nothing to act on. */
|
|
||||||
private const val UNDONE_CHIP_MILLIS = 1_600L
|
|
||||||
|
|
||||||
/** What the chip currently reads, kept past the outcome it was built from. */
|
|
||||||
private data class ChipContent(val message: String, val undo: MoveUndo?)
|
|
||||||
|
|
||||||
/** The FAB's own band at the bottom end, which the chip must not run into. */
|
|
||||||
private val FAB_BAND = 88.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
|
||||||
* recurring-scope prompt, and the confirmation chip carrying Undo. None of the
|
|
||||||
* calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
|
|
||||||
* a pill on the FAB's band, leaving the calendar visible behind it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
|
||||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
|
||||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
|
||||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
|
||||||
val writeInFlight by viewModel.inFlight.collectAsStateWithLifecycle()
|
|
||||||
val locale = currentLocale()
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
|
||||||
|
|
||||||
prompt?.let { pending ->
|
|
||||||
RecurringScopeDialog(
|
|
||||||
title = stringResource(R.string.event_move_recurring_title),
|
|
||||||
onSelect = viewModel::moveWithScope,
|
|
||||||
onDismiss = viewModel::cancelScope,
|
|
||||||
allowSeries = !pending.occurrenceOnly,
|
|
||||||
reason = stringResource(R.string.event_move_occurrence_only)
|
|
||||||
.takeIf { pending.occurrenceOnly },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val moved = outcome as? MoveOutcome.Moved
|
|
||||||
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
|
||||||
val message = when (outcome) {
|
|
||||||
null -> null
|
|
||||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
|
||||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
|
||||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
|
||||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
|
||||||
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
|
||||||
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Held past the outcome being consumed so the chip has something to draw
|
|
||||||
// while it springs back out. Updated in composition rather than from an
|
|
||||||
// effect, which would land a frame late and open the chip on stale text.
|
|
||||||
val shown = remember { mutableStateOf(ChipContent("", null)) }
|
|
||||||
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
|
|
||||||
shown.value = ChipContent(message, moved?.undo)
|
|
||||||
}
|
|
||||||
val content = shown.value
|
|
||||||
// Restarted by the undo tick and held while a write is in flight: an undo
|
|
||||||
// tapped in the last moments of the window leaves the outcome at Moved on
|
|
||||||
// purpose, and this timer would otherwise fire mid-undo and close the chip
|
|
||||||
// just before the same chip has to say "undone".
|
|
||||||
LaunchedEffect(outcome, undoTick, writeInFlight) {
|
|
||||||
if (outcome == null || writeInFlight) return@LaunchedEffect
|
|
||||||
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
|
||||||
viewModel.consumeOutcome()
|
|
||||||
}
|
|
||||||
|
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
|
||||||
val chipMaxWidth = maxWidth - FAB_BAND
|
|
||||||
// A FAB-height band at the bottom start with the FAB's own margin, so
|
|
||||||
// the chip lines up beside the bottom-end FAB.
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
|
||||||
.height(SnackChipHeight),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
// The action goes away while a write runs: undo() refuses a second
|
|
||||||
// write anyway, so offering it would be a button that does nothing.
|
|
||||||
val undo = content.undo?.takeIf { !writeInFlight }
|
|
||||||
SnackChip(
|
|
||||||
visible = outcome != null,
|
|
||||||
message = content.message,
|
|
||||||
maxWidth = chipMaxWidth,
|
|
||||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
|
||||||
onAction = undo?.let { { viewModel.undo(it) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
|
|
||||||
* are read back on the UTC calendar day they are anchored to (#65, #82).
|
|
||||||
*/
|
|
||||||
private fun formatMovedTo(
|
|
||||||
startMillis: Long,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
use24Hour: Boolean,
|
|
||||||
locale: Locale,
|
|
||||||
): String {
|
|
||||||
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
|
|
||||||
val skeleton = when {
|
|
||||||
isAllDay -> "EEEdMMM"
|
|
||||||
use24Hour -> "EEEdMMMHm"
|
|
||||||
else -> "EEEdMMMhm"
|
|
||||||
}
|
|
||||||
return localizedDateFormatter(locale, skeleton)
|
|
||||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
|
||||||
* whichever event block is being composed. Null means moving is off entirely and
|
|
||||||
* blocks register no drag gesture at all.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
class EventMoveScope(
|
|
||||||
/**
|
|
||||||
* Calendars whose events may be moved. Nothing below the UI enforces this —
|
|
||||||
* the repository writes whatever it is handed — so this gate is load-bearing.
|
|
||||||
*/
|
|
||||||
val movableCalendarIds: Set<Long>,
|
|
||||||
/**
|
|
||||||
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the
|
|
||||||
* TalkBack action below stay, since invoking a menu item can't cause the
|
|
||||||
* accidental drags the setting exists to stop.
|
|
||||||
*/
|
|
||||||
val dragEnabled: Boolean,
|
|
||||||
/** False when the drop was refused outright, so nothing will be written. */
|
|
||||||
val move: (MoveRequest) -> Boolean,
|
|
||||||
/**
|
|
||||||
* True while a dropped event is being written, including the time its scope
|
|
||||||
* dialog is up. A flow rather than a value so this scope stays the same
|
|
||||||
* object across a move — every visible block reads it as a composition local.
|
|
||||||
*/
|
|
||||||
val inFlight: StateFlow<Boolean>,
|
|
||||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
|
||||||
val undoStarted: StateFlow<Int>,
|
|
||||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
|
||||||
val edit: (EventInstance) -> Unit,
|
|
||||||
) {
|
|
||||||
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
|
||||||
|
|
||||||
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
|
||||||
|
|
||||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a dropped event is still being written — false wherever moving is off.
|
|
||||||
* The drag overlays hold a landed block on its target for this window.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun moveInFlight(): Boolean {
|
|
||||||
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
|
||||||
return flow.collectAsStateWithLifecycle().value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs [onUndo] when an undo write begins, and never for one that began before
|
|
||||||
* this composable came on screen.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun OnUndoStarted(onUndo: () -> Unit) {
|
|
||||||
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
|
||||||
val tick by flow.collectAsStateWithLifecycle()
|
|
||||||
var seen by remember { mutableIntStateOf(tick) }
|
|
||||||
LaunchedEffect(tick) {
|
|
||||||
if (tick == seen) return@LaunchedEffect
|
|
||||||
seen = tick
|
|
||||||
onUndo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opacity the source block keeps while its floating copy travels. */
|
|
||||||
const val GHOST_ALPHA: Float = 0.3f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opacity for a block whose copy is in flight: ghosted from the lift until the
|
|
||||||
* copy is handed back, then animated up rather than switched, so the block
|
|
||||||
* appears to travel to its new slot instead of vanishing and reappearing.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
|
|
||||||
targetValue = if (lifted) GHOST_ALPHA else 1f,
|
|
||||||
label = "ghost-alpha",
|
|
||||||
).value
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [event] can be picked up and dragged — its calendar allows moving and
|
|
||||||
* the gesture is switched on. Not the same question as [eventMoveAction], which
|
|
||||||
* survives the setting.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun eventDragAllowed(event: EventInstance): Boolean {
|
|
||||||
val move = LocalEventMove.current ?: return false
|
|
||||||
return move.dragEnabled && move.allows(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
|
||||||
* pointer-only. Null when this event can't be moved.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
|
|
||||||
val move = LocalEventMove.current ?: return null
|
|
||||||
if (!move.allows(event)) return null
|
|
||||||
val label = stringResource(R.string.event_move_action)
|
|
||||||
return remember(event.instanceId, label, move) {
|
|
||||||
CustomAccessibilityAction(label) {
|
|
||||||
move.edit(event)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
|
|
||||||
/** How an event chip's title should overflow: what to pass to `Text`. */
|
|
||||||
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overflow for an event chip's title (#164). Chips are narrow enough that the
|
|
||||||
* "…" costs a couple of readable characters, so the title runs to the chip's
|
|
||||||
* edge and clips mid-glyph instead.
|
|
||||||
*
|
|
||||||
* Two cases keep the ellipsis:
|
|
||||||
*
|
|
||||||
* - **[rtl].** With `softWrap` off Compose lays the line out at its full
|
|
||||||
* intrinsic width and clips to the node's left edge, which in RTL is the *end*
|
|
||||||
* of the string — an Arabic title would lose its beginning. The ellipsis
|
|
||||||
* truncates at the logical end in both directions.
|
|
||||||
* - **More than one line** ([singleLine] false). Wrapping needs `softWrap` on,
|
|
||||||
* and clipping with it on breaks the last line at the last whole word — less
|
|
||||||
* title than the ellipsis showed, not more.
|
|
||||||
*/
|
|
||||||
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
|
|
||||||
if (rtl || !singleLine) {
|
|
||||||
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
|
|
||||||
} else {
|
|
||||||
EventTitleOverflow(TextOverflow.Clip, softWrap = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [eventTitleOverflowFor] against the current layout direction. */
|
|
||||||
@Composable
|
|
||||||
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
|
|
||||||
eventTitleOverflowFor(
|
|
||||||
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
|
|
||||||
singleLine = singleLine,
|
|
||||||
)
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
|
|
||||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
|
||||||
val GUTTER_WIDTH = 48.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start inset for the gutter's content (week badge + hour labels) so it centres
|
|
||||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
|
|
||||||
* matching the icon button's centre.
|
|
||||||
*/
|
|
||||||
val GUTTER_CONTENT_START_INSET = 8.dp
|
|
||||||
|
|
||||||
private val BADGE_HEIGHT = 20.dp
|
|
||||||
|
|
||||||
/** How far the fixed hour labels recede while a block is being dragged. */
|
|
||||||
private const val DIMMED_HOUR_ALPHA = 0.3f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
|
||||||
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
|
||||||
* the hour labels and floats a badge with the drag's current start time at the
|
|
||||||
* row the block would land on.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun HourGutter(
|
|
||||||
scrollState: ScrollState,
|
|
||||||
hourHeight: Dp,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
|
||||||
val dragStartMin by remember(dragController) {
|
|
||||||
derivedStateOf { dragController.drag?.startMin }
|
|
||||||
}
|
|
||||||
val hourAlpha by animateFloatAsState(
|
|
||||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
|
||||||
label = "hourLabelAlpha",
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.width(GUTTER_WIDTH)
|
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
(0 until 24).forEach { h ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(hourHeight),
|
|
||||||
) {
|
|
||||||
if (h > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
.copy(alpha = hourAlpha),
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = (-6).dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragStartMin?.let { startMin ->
|
|
||||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = top)
|
|
||||||
.height(BADGE_HEIGHT)
|
|
||||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
|
||||||
.padding(horizontal = 4.dp)
|
|
||||||
// The dragged block already announces this time.
|
|
||||||
.clearAndSetSemantics { },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = formatGutterTime(startMin, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -354,66 +354,35 @@ private fun CustomDaysEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
|
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||||
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
|
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||||
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
|
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||||
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
|
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||||
* confirming a custom value applies via [onSelect] and closes. [label] renders a
|
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||||
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
|
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||||
* the custom preview.
|
* is reused for both the rows and the custom preview.
|
||||||
*
|
|
||||||
* [inheritLabel] adds an exclusive "use the default" row on top, for the
|
|
||||||
* per-calendar pickers; picking it reports null. [selected] is null exactly when
|
|
||||||
* that row is the current choice. [description] explains the setting above the
|
|
||||||
* rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
|
|
||||||
* for settings that clamp their stored value — offering a length the store would
|
|
||||||
* silently shorten is worse than refusing it.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DurationPicker(
|
fun SnoozeDurationPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
presets: List<Int>,
|
||||||
selected: Int?,
|
selected: Int,
|
||||||
label: @Composable (Int) -> String,
|
label: @Composable (Int) -> String,
|
||||||
onSelect: (Int?) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
description: String? = null,
|
|
||||||
inheritLabel: String? = null,
|
|
||||||
maxMinutes: Int = Int.MAX_VALUE,
|
|
||||||
) {
|
) {
|
||||||
val inherits = selected == null
|
val customSelected = selected !in presets
|
||||||
// The current choice when it isn't one of the presets — the Custom row then
|
|
||||||
// names it and the editor opens pre-filled with it.
|
|
||||||
val custom = selected?.takeIf { it !in presets }
|
|
||||||
val rowCount = presets.size + 1 // + the custom row
|
val rowCount = presets.size + 1 // + the custom row
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable {
|
var amountText by rememberSaveable {
|
||||||
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
|
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||||
}
|
}
|
||||||
var unit by rememberSaveable {
|
var unit by rememberSaveable {
|
||||||
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
|
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
if (description != null) PickerDescription(description)
|
|
||||||
if (inheritLabel != null) {
|
|
||||||
GroupedRow(
|
|
||||||
title = inheritLabel,
|
|
||||||
position = Position.Alone,
|
|
||||||
selected = inherits,
|
|
||||||
trailing = if (inherits) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onSelect(null)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
presets.forEachIndexed { index, minute ->
|
presets.forEachIndexed { index, minute ->
|
||||||
val isSelected = minute == selected
|
val isSelected = minute == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -434,10 +403,10 @@ fun DurationPicker(
|
|||||||
// The Custom row connects downward into the editor card when expanded, so
|
// The Custom row connects downward into the editor card when expanded, so
|
||||||
// the two read as one grouped container (the shared custom-expand pattern).
|
// the two read as one grouped container (the shared custom-expand pattern).
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||||
selected = custom != null,
|
selected = customSelected,
|
||||||
trailing = if (custom != null) {
|
trailing = if (customSelected) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -449,13 +418,12 @@ fun DurationPicker(
|
|||||||
enter = expandEnter(),
|
enter = expandEnter(),
|
||||||
exit = collapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomDurationEditor(
|
CustomSnoozeEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
label = label,
|
label = label,
|
||||||
maxMinutes = maxMinutes,
|
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(minutes)
|
onSelect(minutes)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -465,48 +433,41 @@ fun DurationPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole hours if the length divides evenly, else minutes. */
|
/** Whole hours if the delay divides evenly, else minutes. */
|
||||||
private fun durationCustomUnit(minutes: Int): ReminderUnit =
|
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||||
|
|
||||||
private fun durationCustomAmount(minutes: Int): Int =
|
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
|
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||||
* the length it resolves to, and a tonal confirm enabled only for a valid 1–999
|
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
* amount that stays within [maxMinutes]. Over the cap, the preview names the
|
* amount. [onConfirm] receives the final delay in minutes.
|
||||||
* limit instead of a length the setting could not keep. [onConfirm] receives the
|
|
||||||
* final length in minutes.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomDurationEditor(
|
private fun CustomSnoozeEditor(
|
||||||
amountText: String,
|
amountText: String,
|
||||||
onAmountChange: (String) -> Unit,
|
onAmountChange: (String) -> Unit,
|
||||||
unit: ReminderUnit,
|
unit: ReminderUnit,
|
||||||
onUnitChange: (ReminderUnit) -> Unit,
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
label: @Composable (Int) -> String,
|
label: @Composable (Int) -> String,
|
||||||
maxMinutes: Int,
|
|
||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||||
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
val amount = entered?.takeIf { it <= maxMinutes }
|
|
||||||
CustomAmountEditor(
|
CustomAmountEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = onAmountChange,
|
onAmountChange = onAmountChange,
|
||||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||||
onUnitChange = { onUnitChange(units[it]) },
|
onUnitChange = { onUnitChange(units[it]) },
|
||||||
preview = when {
|
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||||
amount != null -> label(amount)
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
|
|
||||||
else -> stringResource(R.string.reminder_custom_amount)
|
|
||||||
},
|
|
||||||
setLabel = stringResource(R.string.reminder_custom_set),
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
confirmEnabled = amount != null,
|
confirmEnabled = amount != null,
|
||||||
onConfirm = { amount?.let(onConfirm) },
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stand-in events for the view previews. A preview has no business querying the
|
|
||||||
* provider for a thumbnail, so every chooser renders the same invented week.
|
|
||||||
*
|
|
||||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
|
||||||
* event, so a theme token here would misrepresent what the views actually draw.
|
|
||||||
*/
|
|
||||||
private val SAMPLE_COLORS = listOf(
|
|
||||||
0xFF3F7BD4.toInt(),
|
|
||||||
0xFFCE5B4C.toInt(),
|
|
||||||
0xFF4E9A6A.toInt(),
|
|
||||||
0xFF8A63C7.toInt(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val SAMPLE_TITLES = listOf(
|
|
||||||
"Standup",
|
|
||||||
"Lunch",
|
|
||||||
"Review",
|
|
||||||
"Gym",
|
|
||||||
"Call",
|
|
||||||
"Workshop",
|
|
||||||
"Dentist",
|
|
||||||
"Trip",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
|
|
||||||
private class SampleEvents(private val zone: TimeZone) {
|
|
||||||
private var id = 0L
|
|
||||||
|
|
||||||
fun timed(
|
|
||||||
day: LocalDate,
|
|
||||||
hour: Int,
|
|
||||||
minute: Int = 0,
|
|
||||||
lengthMinutes: Int,
|
|
||||||
colorIndex: Int,
|
|
||||||
): EventInstance {
|
|
||||||
val start = day.atTime(hour, minute).toInstant(zone)
|
|
||||||
return build(
|
|
||||||
start = start,
|
|
||||||
end = start + lengthMinutes.minutes,
|
|
||||||
isAllDay = false,
|
|
||||||
colorIndex = colorIndex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
|
|
||||||
// All-day events sit at UTC midnights with an exclusive end.
|
|
||||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
|
||||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
|
||||||
isAllDay = true,
|
|
||||||
colorIndex = colorIndex,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun build(
|
|
||||||
start: Instant,
|
|
||||||
end: Instant,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
colorIndex: Int,
|
|
||||||
): EventInstance {
|
|
||||||
val next = ++id
|
|
||||||
return EventInstance(
|
|
||||||
instanceId = next,
|
|
||||||
eventId = next,
|
|
||||||
calendarId = 1L,
|
|
||||||
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
|
|
||||||
start = start,
|
|
||||||
end = end,
|
|
||||||
isAllDay = isAllDay,
|
|
||||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
|
||||||
location = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A month's worth of stand-ins, spread so most weeks carry something, with one
|
|
||||||
* multi-day bar bridging cells and a busy today for the split pane.
|
|
||||||
*/
|
|
||||||
internal fun sampleMonthEvents(
|
|
||||||
firstOfMonth: LocalDate,
|
|
||||||
today: LocalDate,
|
|
||||||
zone: TimeZone,
|
|
||||||
): List<EventInstance> {
|
|
||||||
val sample = SampleEvents(zone)
|
|
||||||
return buildList {
|
|
||||||
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
|
||||||
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
|
|
||||||
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
|
|
||||||
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
|
||||||
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
|
|
||||||
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
|
|
||||||
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
|
|
||||||
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
|
|
||||||
// Today, so the split pane's list and the grid's dots both have content.
|
|
||||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
|
||||||
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
|
|
||||||
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
|
|
||||||
* is centred on — gets the busy set, including one overlapping pair so the
|
|
||||||
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
|
|
||||||
* all-day bar is only added when there is more than one column to span.
|
|
||||||
*/
|
|
||||||
internal fun sampleTimelineEvents(
|
|
||||||
days: List<LocalDate>,
|
|
||||||
anchor: LocalDate,
|
|
||||||
zone: TimeZone,
|
|
||||||
): List<EventInstance> {
|
|
||||||
val sample = SampleEvents(zone)
|
|
||||||
return buildList {
|
|
||||||
if (days.size > 1) {
|
|
||||||
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
|
|
||||||
} else {
|
|
||||||
add(sample.allDay(anchor, days = 1, colorIndex = 2))
|
|
||||||
}
|
|
||||||
// The anchor day: enough to fill the column, with 09:30–11:00 running
|
|
||||||
// under 09:00–10:00 so the overlap resolves into two lanes.
|
|
||||||
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
|
|
||||||
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
|
|
||||||
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
|
|
||||||
days.filterNot { it == anchor }.forEachIndexed { index, day ->
|
|
||||||
// Skip every third day so the week doesn't read as a solid block.
|
|
||||||
if (index % 3 == 2) return@forEachIndexed
|
|
||||||
add(
|
|
||||||
sample.timed(
|
|
||||||
day = day,
|
|
||||||
hour = 8 + (index * 3) % 9,
|
|
||||||
lengthMinutes = if (index % 2 == 0) 60 else 90,
|
|
||||||
colorIndex = index,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
|
|
||||||
* days are busy and later ones thin out, so the grouped-by-day list shows both
|
|
||||||
* a full day and the gaps between days.
|
|
||||||
*/
|
|
||||||
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
|
||||||
val sample = SampleEvents(zone)
|
|
||||||
return buildList {
|
|
||||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
|
||||||
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
|
|
||||||
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
|
|
||||||
val tomorrow = today.plus(1, DateTimeUnit.DAY)
|
|
||||||
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
|
|
||||||
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
|
|
||||||
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
|
|
||||||
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
|
||||||
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far a write to a recurring event should reach: this occurrence, it and
|
|
||||||
* everything after (a series split), or the whole series. Shared by the edit
|
|
||||||
* screen's save and a drag-and-drop reschedule; one of the two carve-outs from
|
|
||||||
* the full-screen picker rule.
|
|
||||||
*
|
|
||||||
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
|
||||||
* rule); [allowSeries] drops the two wider options, for a rule whose days can't
|
|
||||||
* be recalculated from one moved occurrence — [reason] then says why.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun RecurringScopeDialog(
|
|
||||||
title: String,
|
|
||||||
onSelect: (RecurringWriteScope) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
allowOccurrence: Boolean = true,
|
|
||||||
allowSeries: Boolean = true,
|
|
||||||
reason: String? = null,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(title) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
if (reason != null) {
|
|
||||||
Text(
|
|
||||||
text = reason,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (allowOccurrence) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_occurrence),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (allowSeries) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_following),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_series),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -37,21 +37,3 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
|
|||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
|
|
||||||
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
|
|
||||||
* the default event duration (#54).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun durationLabel(minutes: Int): String {
|
|
||||||
val hours = minutes / 60
|
|
||||||
val rest = minutes % 60
|
|
||||||
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
|
|
||||||
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
|
|
||||||
return when {
|
|
||||||
hours == 0 -> minutesLabel
|
|
||||||
rest == 0 -> hoursLabel
|
|
||||||
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,463 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
|
||||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
|
||||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val MILLIS_PER_MINUTE = 60_000
|
|
||||||
private const val MINUTES_PER_DAY = 24 * 60
|
|
||||||
|
|
||||||
/** Where a dragged event should land. */
|
|
||||||
sealed interface MoveTarget {
|
|
||||||
/** A new start instant — a timeline drag, which moves time and day at once. */
|
|
||||||
data class Start(val instant: Instant) : MoveTarget
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
|
|
||||||
* A delta rather than a target date, so the screen and this view model can't
|
|
||||||
* disagree by a day over which zone the event's first day is resolved in.
|
|
||||||
*/
|
|
||||||
data class ByDays(val days: Int) : MoveTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
|
||||||
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
|
|
||||||
*/
|
|
||||||
data class MoveRequest(
|
|
||||||
val eventId: Long,
|
|
||||||
val beginMillis: Long,
|
|
||||||
val endMillis: Long,
|
|
||||||
val target: MoveTarget,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A recurring drop waiting for the user to pick how far it reaches.
|
|
||||||
* [occurrenceOnly] means a wider write would leave the rule and the series
|
|
||||||
* anchor disagreeing — either the rule names days one moved occurrence can't
|
|
||||||
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
|
||||||
* across the same midnights the occurrence crossed.
|
|
||||||
*/
|
|
||||||
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
|
||||||
|
|
||||||
/** The inverse of a completed move, for the undo action. */
|
|
||||||
data class MoveUndo(
|
|
||||||
val eventId: Long,
|
|
||||||
/** The form as it now stands (the moved state). */
|
|
||||||
val moved: EventForm,
|
|
||||||
/** The form as it stood before the move. */
|
|
||||||
val restored: EventForm,
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed interface MoveOutcome {
|
|
||||||
/**
|
|
||||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
|
||||||
* confirmation message; [undo] is null when the write has no clean inverse.
|
|
||||||
*/
|
|
||||||
data class Moved(
|
|
||||||
val startMillis: Long,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
val undo: MoveUndo?,
|
|
||||||
) : MoveOutcome
|
|
||||||
|
|
||||||
data object Undone : MoveOutcome
|
|
||||||
|
|
||||||
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
|
|
||||||
data object WriteDenied : MoveOutcome
|
|
||||||
|
|
||||||
/** The event vanished (sync, another device) between the read and the write. */
|
|
||||||
data object Gone : MoveOutcome
|
|
||||||
|
|
||||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
|
||||||
data object BlockedSeriesEnd : MoveOutcome
|
|
||||||
|
|
||||||
data object Failed : MoveOutcome
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
|
|
||||||
* edit screen's save uses, so recurring writes, reminder reconciliation and
|
|
||||||
* attendee preservation behave identically. Hosted at `CalendarHost` so one
|
|
||||||
* instance serves every calendar view and survives view switches. The full
|
|
||||||
* prefilled form is carried through the write — a stripped one would wipe the
|
|
||||||
* reminders and attendees the occurrence-exception path reconciles.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class RescheduleViewModel @Inject constructor(
|
|
||||||
private val repository: CalendarRepository,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
|
||||||
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
|
|
||||||
|
|
||||||
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
|
|
||||||
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
|
|
||||||
|
|
||||||
private var pending: PreparedMove? = null
|
|
||||||
|
|
||||||
private val _inFlight = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True from the moment a drop is accepted until its write settles — the
|
|
||||||
* window the dropped block holds its landing position for.
|
|
||||||
*/
|
|
||||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
|
||||||
|
|
||||||
private val _undoStarted = MutableStateFlow(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ticks the moment an undo write begins, before the provider has anything to
|
|
||||||
* re-read, so the view that drew the drop can carry its chip back rather than
|
|
||||||
* let it reappear on the old day.
|
|
||||||
*/
|
|
||||||
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
|
||||||
* of the same recurring event inside that window would compute their shift
|
|
||||||
* from the same pre-move occurrence, and the shifts would compound.
|
|
||||||
*/
|
|
||||||
private var busy = false
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
_inFlight.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The calendars whose events may be dragged. Nothing below the UI guards
|
|
||||||
* this, so a block outside this set registers no drag gesture at all.
|
|
||||||
*/
|
|
||||||
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
|
|
||||||
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
|
||||||
.catch { emit(emptySet<Long>()) }
|
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Everything a drop needs, resolved before anything is written. */
|
|
||||||
private data class PreparedMove(
|
|
||||||
val request: MoveRequest,
|
|
||||||
val original: EventForm,
|
|
||||||
val updated: EventForm,
|
|
||||||
/** True for a series master with a rule — an exception row is not one. */
|
|
||||||
val isRecurring: Boolean,
|
|
||||||
/** False when the rule names days this move can't re-derive. */
|
|
||||||
val canRealign: Boolean,
|
|
||||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
|
||||||
val newAnchorDate: LocalDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take a drop, unless one is already being written. False means nothing will
|
|
||||||
* be written and the caller must let its held copy go now — waiting on
|
|
||||||
* [inFlight] would strand it until the settle timeout instead.
|
|
||||||
*/
|
|
||||||
fun move(request: MoveRequest): Boolean {
|
|
||||||
if (busy || _scopePrompt.value != null) return false
|
|
||||||
busy = true
|
|
||||||
viewModelScope.launch {
|
|
||||||
val prepared = prepare(request)
|
|
||||||
if (prepared == null) {
|
|
||||||
busy = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
if (!prepared.isRecurring) {
|
|
||||||
write(prepared, RecurringWriteScope.AllEvents)
|
|
||||||
busy = false
|
|
||||||
} else {
|
|
||||||
// Still busy: the scope dialog is now the thing in flight.
|
|
||||||
pending = prepared
|
|
||||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer the scope dialog. */
|
|
||||||
fun moveWithScope(scope: RecurringWriteScope) {
|
|
||||||
val prepared = pending ?: return
|
|
||||||
// Guard against the dialog ever offering a scope the rule can't carry.
|
|
||||||
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
|
||||||
pending = null
|
|
||||||
_scopePrompt.value = null
|
|
||||||
viewModelScope.launch {
|
|
||||||
write(prepared, scope)
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dismiss the scope dialog without writing. */
|
|
||||||
fun cancelScope() {
|
|
||||||
pending = null
|
|
||||||
busy = false
|
|
||||||
_scopePrompt.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put a completed move back where it came from. The outcome stands until the
|
|
||||||
* inverse write reports back, so the one chip changes what it says rather
|
|
||||||
* than closing and reopening. False when another write is already running —
|
|
||||||
* the chip hides its action for that window, so this is the backstop.
|
|
||||||
*/
|
|
||||||
fun undo(undo: MoveUndo): Boolean {
|
|
||||||
if (busy) return false
|
|
||||||
busy = true
|
|
||||||
_undoStarted.value += 1
|
|
||||||
viewModelScope.launch {
|
|
||||||
_outcome.value = try {
|
|
||||||
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
|
||||||
MoveOutcome.Undone
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
MoveOutcome.WriteDenied
|
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
MoveOutcome.Gone
|
|
||||||
} catch (e: Exception) {
|
|
||||||
MoveOutcome.Failed
|
|
||||||
}
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear the outcome once the screen has shown it. */
|
|
||||||
fun consumeOutcome() {
|
|
||||||
_outcome.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
|
||||||
val detail = try {
|
|
||||||
repository.eventDetail(request.eventId)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
_outcome.value = MoveOutcome.Gone
|
|
||||||
return null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_outcome.value = MoveOutcome.Failed
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val zone = TimeZone.currentSystemDefault()
|
|
||||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
|
||||||
val shifted = when (val target = request.target) {
|
|
||||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
|
||||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
|
||||||
}
|
|
||||||
// A zero-distance drop is not a write.
|
|
||||||
if (shifted == original) return null
|
|
||||||
|
|
||||||
// The UNTIL check is deferred to the write, which knows how far the move
|
|
||||||
// reaches and so which date has to clear it.
|
|
||||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
|
||||||
_outcome.value = MoveOutcome.Failed
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// An exception row stands alone whatever rule a sync adapter left on it.
|
|
||||||
val isRecurring = original.rrule != null && !detail.isException
|
|
||||||
val movedDay = shifted.start.date != original.start.date
|
|
||||||
// The series anchor moves by the same *wall-clock* shift as the dragged
|
|
||||||
// occurrence, so only a whole-day shift carries it across the same number
|
|
||||||
// of midnights whatever time of day it sits at. A same-date drag has its
|
|
||||||
// own, narrower check — see anchorKeepsItsDay.
|
|
||||||
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
|
|
||||||
val realigned = if (isRecurring && movedDay) {
|
|
||||||
if (wholeDayShift) {
|
|
||||||
realignRecurrence(
|
|
||||||
requireNotNull(original.rrule),
|
|
||||||
original.start.date,
|
|
||||||
shifted.start.date,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
original.rrule
|
|
||||||
}
|
|
||||||
return PreparedMove(
|
|
||||||
request = request,
|
|
||||||
original = original,
|
|
||||||
// A rule we can't re-derive stays verbatim: the only scope offered
|
|
||||||
// then is the single occurrence, whose exception row carries no rule.
|
|
||||||
updated = shifted.copy(rrule = realigned ?: original.rrule),
|
|
||||||
isRecurring = isRecurring,
|
|
||||||
canRealign = when {
|
|
||||||
!isRecurring -> true
|
|
||||||
movedDay -> realigned != null
|
|
||||||
else -> anchorKeepsItsDay(detail, original, shifted, zone)
|
|
||||||
},
|
|
||||||
// Where the series row's own DTSTART lands, given the anchor moves by
|
|
||||||
// the same shift. Only read under canRealign, which is what
|
|
||||||
// guarantees the anchor really does travel this many days.
|
|
||||||
newAnchorDate = anchorDate(detail, original, zone)
|
|
||||||
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
|
||||||
val request = prepared.request
|
|
||||||
if (endsBeforeItStarts(prepared, scope)) {
|
|
||||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_outcome.value = try {
|
|
||||||
when {
|
|
||||||
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
|
||||||
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
|
||||||
|
|
||||||
scope == RecurringWriteScope.ThisEvent ->
|
|
||||||
repository.updateOccurrence(
|
|
||||||
request.eventId,
|
|
||||||
request.beginMillis,
|
|
||||||
prepared.updated,
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> repository.updateEventFromOccurrence(
|
|
||||||
eventId = request.eventId,
|
|
||||||
beginMillis = request.beginMillis,
|
|
||||||
original = prepared.original,
|
|
||||||
updated = prepared.updated,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
MoveOutcome.Moved(
|
|
||||||
startMillis = movedStartMillis(prepared),
|
|
||||||
isAllDay = prepared.updated.isAllDay,
|
|
||||||
undo = undoFor(prepared, scope),
|
|
||||||
)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
MoveOutcome.WriteDenied
|
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
MoveOutcome.Gone
|
|
||||||
} catch (e: Exception) {
|
|
||||||
MoveOutcome.Failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this write would leave a rule whose `UNTIL` precedes the first day
|
|
||||||
* it now applies to — the provider then generates nothing at all. Which date
|
|
||||||
* has to clear `UNTIL` depends on the scope: a whole-series move carries the
|
|
||||||
* series *anchor*, a split starts at the moved occurrence, and a single
|
|
||||||
* occurrence becomes an exception row that no `UNTIL` constrains.
|
|
||||||
*/
|
|
||||||
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
|
||||||
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
|
|
||||||
val rule = prepared.updated.rrule ?: return false
|
|
||||||
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
|
|
||||||
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
|
|
||||||
prepared.newAnchorDate
|
|
||||||
} else {
|
|
||||||
prepared.updated.start.date
|
|
||||||
}
|
|
||||||
return end.date < firstDay
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a same-date drag leaves the series anchor on its own day too. The
|
|
||||||
* anchor moves by the same wall-clock delta as the occurrence, and normally
|
|
||||||
* shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the
|
|
||||||
* two in zones that can sit a DST hour apart, so a near-midnight drag could
|
|
||||||
* carry the anchor across a midnight the occurrence never crossed and leave
|
|
||||||
* `BYDAY` naming the wrong weekday.
|
|
||||||
*/
|
|
||||||
private fun anchorKeepsItsDay(
|
|
||||||
detail: EventDetail,
|
|
||||||
original: EventForm,
|
|
||||||
shifted: EventForm,
|
|
||||||
zone: TimeZone,
|
|
||||||
): Boolean {
|
|
||||||
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
|
||||||
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
|
||||||
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
|
||||||
val delta = (
|
|
||||||
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
|
||||||
) / MILLIS_PER_MINUTE
|
|
||||||
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The series row's own start date — for a recurring master, `EventDetail`
|
|
||||||
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
|
|
||||||
* same anchoring the write path uses: UTC for an all-day series.
|
|
||||||
*/
|
|
||||||
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
|
|
||||||
detail.instance.start
|
|
||||||
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
|
|
||||||
.date
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo is offered only where the inverse is one symmetric write: a
|
|
||||||
* non-recurring event, and a whole-series move. "This event" leaves an
|
|
||||||
* exception row behind and "this and following" splits the series with an
|
|
||||||
* UNTIL truncation — neither is undone by shifting back.
|
|
||||||
*/
|
|
||||||
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
|
|
||||||
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
|
|
||||||
MoveUndo(
|
|
||||||
eventId = prepared.request.eventId,
|
|
||||||
moved = prepared.updated,
|
|
||||||
restored = prepared.original,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the dragged occurrence now begins, in the same anchoring the views
|
|
||||||
* read dates back in: UTC midnight for an all-day event.
|
|
||||||
*/
|
|
||||||
private fun movedStartMillis(prepared: PreparedMove): Long {
|
|
||||||
val form = prepared.updated
|
|
||||||
return if (form.isAllDay) {
|
|
||||||
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
|
||||||
} else {
|
|
||||||
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
|
||||||
.toEpochMilliseconds()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
|||||||
private const val PATTERN_24 = "HH:mm"
|
private const val PATTERN_24 = "HH:mm"
|
||||||
private const val PATTERN_12 = "h:mm a"
|
private const val PATTERN_12 = "h:mm a"
|
||||||
private const val HOUR_PATTERN_12 = "h a"
|
private const val HOUR_PATTERN_12 = "h a"
|
||||||
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
|
|
||||||
|
|
||||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||||
@@ -43,18 +42,6 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
|
|||||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The time shown in the timeline gutter while a block is dragged: 24h →
|
|
||||||
* "09:15", 12h → "9:15". The meridiem is dropped — the hour labels around it
|
|
||||||
* already carry it, and the gutter is too narrow.
|
|
||||||
*/
|
|
||||||
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
|
|
||||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
|
||||||
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
|
|
||||||
return LocalTime.of(clamped / 60, clamped % 60)
|
|
||||||
.format(DateTimeFormatter.ofPattern(pattern, locale))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||||
* the prior look); 12h → "1 PM".
|
* the prior look); 12h → "1 PM".
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.foundation.MutatePriority
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.absoluteOffset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.withFrameNanos
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
|
||||||
const val DRAG_SNAP_MINUTES: Int = 15
|
|
||||||
|
|
||||||
/** How close to a timeline edge the finger must get before the view scrolls. */
|
|
||||||
private val AUTO_SCROLL_EDGE = 64.dp
|
|
||||||
|
|
||||||
/** Fastest auto-scroll step, per frame, right at the edge. */
|
|
||||||
private val AUTO_SCROLL_STEP = 16.dp
|
|
||||||
|
|
||||||
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
|
|
||||||
data class TimelineDrag(
|
|
||||||
val event: EventInstance,
|
|
||||||
val date: LocalDate,
|
|
||||||
val startMin: Int,
|
|
||||||
val endMin: Int,
|
|
||||||
val topLeftInRoot: Offset,
|
|
||||||
val sizePx: IntSize,
|
|
||||||
) {
|
|
||||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
|
||||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where a finished drag asks its event to go. */
|
|
||||||
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
|
||||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
|
||||||
* it per frame anyway.
|
|
||||||
*/
|
|
||||||
class TimelineGeometry {
|
|
||||||
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
|
|
||||||
var grid: LayoutCoordinates? = null
|
|
||||||
|
|
||||||
/** The scroll viewport, for the edge that triggers auto-scrolling. */
|
|
||||||
var viewport: LayoutCoordinates? = null
|
|
||||||
var scroll: ScrollState? = null
|
|
||||||
var hourPx: Float = 0f
|
|
||||||
|
|
||||||
/** Column pitch — one column plus the gap after it. */
|
|
||||||
var columnWidthPx: Float = 0f
|
|
||||||
var columnGapPx: Float = 0f
|
|
||||||
var edgePx: Float = 0f
|
|
||||||
var stepPx: Float = 0f
|
|
||||||
var days: List<LocalDate> = emptyList()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
|
||||||
* never mirrored but the grid is, so the mapping has to flip with it.
|
|
||||||
*/
|
|
||||||
var isRtl: Boolean = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
|
||||||
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
|
||||||
* block it renders is drawn in an overlay, clear of the day column's clip.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
class TimelineDragController {
|
|
||||||
val geometry = TimelineGeometry()
|
|
||||||
|
|
||||||
var drag: TimelineDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A dropped block, held at the slot it landed on while the write runs — the
|
|
||||||
* grid behind it still shows the old time until the query re-reads.
|
|
||||||
*/
|
|
||||||
var settling: TimelineDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which block is lifted, and whether anything is. Separate snapshot state
|
|
||||||
* from [drag], which changes every frame, so blocks that only need "am I the
|
|
||||||
* ghost" don't recompose that often. Stays set through [settling].
|
|
||||||
*/
|
|
||||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the settled drop came from, and which event row it belongs to. The
|
|
||||||
* instance id alone can't identify the source block for the length of the
|
|
||||||
* write: the provider regenerates `Instances` rows, so a re-read carrying the
|
|
||||||
* *old* time can arrive under a new instance id.
|
|
||||||
*/
|
|
||||||
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
|
||||||
|
|
||||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
|
||||||
var isDragging: Boolean by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
|
||||||
* The copy may only be handed back once this is true, or the source ghost
|
|
||||||
* flashes back to full opacity in its old slot.
|
|
||||||
*/
|
|
||||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var source: TimedBlock? = null
|
|
||||||
private var grab = Offset.Zero
|
|
||||||
private var pointer = Offset.Zero
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The slot the block already occupied when it was picked up — not the same
|
|
||||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
|
||||||
*/
|
|
||||||
private var originSlot: Pair<LocalDate, Int>? = null
|
|
||||||
|
|
||||||
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
|
||||||
source = block
|
|
||||||
settling = null
|
|
||||||
isDragging = true
|
|
||||||
liftedInstanceId = block.event.instanceId
|
|
||||||
grab = pointerInRoot - blockInRoot
|
|
||||||
pointer = pointerInRoot
|
|
||||||
originSlot = null
|
|
||||||
recompute()
|
|
||||||
originSlot = drag?.slot
|
|
||||||
}
|
|
||||||
|
|
||||||
fun move(pointerInRoot: Offset) {
|
|
||||||
pointer = pointerInRoot
|
|
||||||
recompute()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
source = null
|
|
||||||
isDragging = false
|
|
||||||
liftedInstanceId = null
|
|
||||||
originSlot = null
|
|
||||||
drag = null
|
|
||||||
settling = null
|
|
||||||
settledOnGrid = false
|
|
||||||
settledOrigin = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [block] is the one whose copy is in flight, and so must stay a
|
|
||||||
* ghost: the instance the finger picked up, or once dropped whatever now
|
|
||||||
* sits in the slot it left.
|
|
||||||
*/
|
|
||||||
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
|
||||||
if (liftedInstanceId == null) return false
|
|
||||||
if (block.event.instanceId == liftedInstanceId) return true
|
|
||||||
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
|
||||||
return block.event.eventId == eventId &&
|
|
||||||
date == originDate &&
|
|
||||||
block.startMin == originMin
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a day column now holds, so a settled drop can tell when the grid has
|
|
||||||
* caught up with it. Matched on the landing slot plus either the event row
|
|
||||||
* or its title, since a single-occurrence move writes a new `eventId`.
|
|
||||||
*/
|
|
||||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
|
||||||
val landed = settling ?: return
|
|
||||||
if (settledOnGrid || landed.date != date) return
|
|
||||||
settledOnGrid = blocks.any { block ->
|
|
||||||
block.startMin == landed.startMin &&
|
|
||||||
(
|
|
||||||
block.event.eventId == landed.event.eventId ||
|
|
||||||
block.event.title == landed.event.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End the drag, handing back where it landed — null when it never resolved,
|
|
||||||
* or when it landed back on the slot it started from. A real drop keeps its
|
|
||||||
* copy on the target as [settling] until [release].
|
|
||||||
*/
|
|
||||||
fun finish(): TimelineDrop? {
|
|
||||||
val landed = drag
|
|
||||||
val origin = originSlot
|
|
||||||
cancel()
|
|
||||||
if (landed == null || landed.slot == origin) return null
|
|
||||||
settling = landed
|
|
||||||
liftedInstanceId = landed.event.instanceId
|
|
||||||
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
|
||||||
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop ghosting the source — the grid draws the drop itself by now — while
|
|
||||||
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
|
||||||
* the block dim under the fading copy and then brighten it: a visible dip.
|
|
||||||
*/
|
|
||||||
fun handOver() {
|
|
||||||
liftedInstanceId = null
|
|
||||||
settledOrigin = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the copy, once it has faded into the grid's own block. */
|
|
||||||
fun release() {
|
|
||||||
settling = null
|
|
||||||
settledOnGrid = false
|
|
||||||
handOver()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-derive the target from the pointer's *root* position. Recomputed rather
|
|
||||||
* than accumulated from `positionChange()`, because a stationary finger emits
|
|
||||||
* no move events while auto-scroll walks the content underneath it.
|
|
||||||
*/
|
|
||||||
fun recompute() {
|
|
||||||
val block = source ?: return
|
|
||||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
|
||||||
val hourPx = geometry.hourPx
|
|
||||||
val columnPx = geometry.columnWidthPx
|
|
||||||
val days = geometry.days
|
|
||||||
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
|
|
||||||
|
|
||||||
val origin = grid.positionInRoot()
|
|
||||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
|
||||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
|
||||||
// Clamp the start into the target day; a tail past midnight is fine.
|
|
||||||
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
|
||||||
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
|
||||||
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
|
||||||
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
|
||||||
// The column the finger is over on screen, and the day that column shows.
|
|
||||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
|
||||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
|
||||||
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
|
||||||
drag = TimelineDrag(
|
|
||||||
event = block.event,
|
|
||||||
date = days[dayIndex],
|
|
||||||
startMin = startMin,
|
|
||||||
endMin = startMin + span,
|
|
||||||
topLeftInRoot = Offset(
|
|
||||||
x = origin.x + column * columnPx,
|
|
||||||
y = origin.y + startMin / 60f * hourPx,
|
|
||||||
),
|
|
||||||
sizePx = IntSize(
|
|
||||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
|
||||||
height.roundToInt(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hold the scroll for the whole drag and nudge it once per frame while the
|
|
||||||
* finger sits near an edge. One `scroll` call, since a loop of
|
|
||||||
* `animateScrollBy` would re-acquire the mutex and cancel the last.
|
|
||||||
*/
|
|
||||||
suspend fun autoScroll() {
|
|
||||||
val scroll = geometry.scroll ?: return
|
|
||||||
scroll.scroll(MutatePriority.UserInput) {
|
|
||||||
while (true) {
|
|
||||||
withFrameNanos { }
|
|
||||||
recompute()
|
|
||||||
val step = edgeStep()
|
|
||||||
if (step != 0f) scrollBy(step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun edgeStep(): Float {
|
|
||||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
|
||||||
val edge = geometry.edgePx
|
|
||||||
if (edge <= 0f) return 0f
|
|
||||||
val bounds = viewport.boundsInRoot()
|
|
||||||
val fromTop = pointer.y - bounds.top
|
|
||||||
val fromBottom = bounds.bottom - pointer.y
|
|
||||||
return when {
|
|
||||||
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
|
||||||
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberTimelineDragController(): TimelineDragController {
|
|
||||||
val controller = remember { TimelineDragController() }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
|
||||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [block] actually begins on [day] rather than being the tail of an
|
|
||||||
* event that started earlier. A clipped block's top edge is midnight, not the
|
|
||||||
* event's start, so dragging it would move the event to a time it never had.
|
|
||||||
*/
|
|
||||||
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
|
||||||
event.start.toLocalDateTime(zone).date == day
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
|
||||||
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
|
||||||
* forward by the missing hour, and the block visibly settles there.
|
|
||||||
*/
|
|
||||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
|
||||||
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A beat after the grid draws the drop, so its own block is under way before the
|
|
||||||
* copy starts dissolving.
|
|
||||||
*/
|
|
||||||
const val SETTLE_GRACE_MILLIS: Long = 60L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long to wait for a grid that never confirms the drop — it landed on a day
|
|
||||||
* this timeline doesn't show, or the write failed. The copy has to go either way.
|
|
||||||
*/
|
|
||||||
private const val SETTLE_TIMEOUT_MILLIS = 900L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The hand-over: the copy dissolves over this while the grid's own block slides
|
|
||||||
* in under it. Kept short, because a full-width copy sits over any neighbour it
|
|
||||||
* now shares a lane with until it is gone.
|
|
||||||
*/
|
|
||||||
const val SETTLE_FADE_MILLIS: Int = 250
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The floating block, drawn over the whole calendar so it is free of the day
|
|
||||||
* column's clip and of the scroll viewport's rounded corners.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val moveInFlight = moveInFlight()
|
|
||||||
|
|
||||||
// Both live here rather than beside the controller: they read [drag], which
|
|
||||||
// changes every frame, and this composable is the one that is meant to.
|
|
||||||
DragSnapHaptics(controller.drag?.slot)
|
|
||||||
LaunchedEffect(controller.isDragging) {
|
|
||||||
if (controller.isDragging) controller.autoScroll()
|
|
||||||
}
|
|
||||||
// Hold the landed copy until the write is done — including the time a
|
|
||||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
|
||||||
// drop itself.
|
|
||||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
|
||||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
|
||||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
|
||||||
handingOver = true
|
|
||||||
controller.handOver()
|
|
||||||
delay(SETTLE_FADE_MILLIS.toLong())
|
|
||||||
controller.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
// A copy of a block still in the tree behind it; announcing it again
|
|
||||||
// would duplicate the event for the drag's duration.
|
|
||||||
.clearAndSetSemantics { }
|
|
||||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
|
||||||
) {
|
|
||||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
|
||||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
|
||||||
val landed = controller.drag == null
|
|
||||||
val lift by animateFloatAsState(
|
|
||||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
|
||||||
label = "drag-lift",
|
|
||||||
)
|
|
||||||
val copyAlpha by animateFloatAsState(
|
|
||||||
targetValue = if (handingOver) 0f else 1f,
|
|
||||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
|
||||||
label = "drag-handover",
|
|
||||||
)
|
|
||||||
val fill = eventFill(drag.event.color, dark, soften)
|
|
||||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
// An end past midnight wraps rather than saturating, so a four-hour
|
|
||||||
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
|
||||||
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
|
||||||
drag.endMin % MINUTES_PER_DAY
|
|
||||||
} else {
|
|
||||||
drag.endMin
|
|
||||||
}
|
|
||||||
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
|
||||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
|
||||||
.absoluteOffset {
|
|
||||||
IntOffset(
|
|
||||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
|
||||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.size(
|
|
||||||
width = with(density) { drag.sizePx.width.toDp() },
|
|
||||||
height = with(density) { drag.sizePx.height.toDp() },
|
|
||||||
)
|
|
||||||
.padding(horizontal = 1.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = 1f + 0.02f * lift
|
|
||||||
scaleY = 1f + 0.02f * lift
|
|
||||||
shadowElevation = 8.dp.toPx() * lift
|
|
||||||
alpha = copyAlpha
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
clip = false
|
|
||||||
}
|
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
val titleOverflow = eventTitleOverflow()
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = titleOverflow.overflow,
|
|
||||||
softWrap = titleOverflow.softWrap,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How tall one hour is drawn in the week and day timelines (#56).
|
|
||||||
*
|
|
||||||
* One shared setting for both views: they are the same grid at different widths,
|
|
||||||
* and a scale that only applied to one of them would read as a bug.
|
|
||||||
*
|
|
||||||
* [FitDay] is the answer to the actual complaint behind the issue — on a tall
|
|
||||||
* phone the default scale shows about half a day, so a whole week can hide
|
|
||||||
* appointments below the fold. It derives the hour height from the timeline's
|
|
||||||
* own viewport instead of a fixed value.
|
|
||||||
*
|
|
||||||
* [Custom] is what a pinch on the timeline leaves behind. The presets are the
|
|
||||||
* settings-screen vocabulary; a pinch is a direct manipulation and should be
|
|
||||||
* able to land anywhere between them, so it gets its own case rather than
|
|
||||||
* snapping to the nearest named step.
|
|
||||||
*/
|
|
||||||
sealed interface TimelineScale {
|
|
||||||
|
|
||||||
/** Whole day in one screen: the hour height follows the viewport. */
|
|
||||||
data object FitDay : TimelineScale
|
|
||||||
|
|
||||||
/** Denser than the default, still a fixed height. */
|
|
||||||
data object Compact : TimelineScale
|
|
||||||
|
|
||||||
/** The historical 56dp scale. */
|
|
||||||
data object Regular : TimelineScale
|
|
||||||
|
|
||||||
/** Roomier blocks, more scrolling. */
|
|
||||||
data object Comfortable : TimelineScale
|
|
||||||
|
|
||||||
/** A height the user pinched to. Build it through [custom], which clamps. */
|
|
||||||
data class Custom(val hourHeight: Dp) : TimelineScale
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** The named steps the settings picker offers, coarse to roomy. */
|
|
||||||
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pinched hour height. The floor a pinch actually stops at depends on
|
|
||||||
* the viewport and is applied when the height is resolved (see
|
|
||||||
* [hourHeight]); this only holds a stored value to something sane.
|
|
||||||
*/
|
|
||||||
fun custom(hourHeight: Dp): Custom =
|
|
||||||
Custom(hourHeight.coerceIn(MIN_STORED_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hour height for this scale. [viewportHeight] is the visible height of the
|
|
||||||
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
|
||||||
*
|
|
||||||
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
|
|
||||||
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
|
|
||||||
* blocks absurdly. On a screen too short for the whole day the clamp wins and
|
|
||||||
* the timeline still scrolls a little — honest, rather than unreadable.
|
|
||||||
*
|
|
||||||
* A pinched height is held to [fillHourHeight] here and not only in the gesture,
|
|
||||||
* because the viewport can change under a height that was already stored: pinch
|
|
||||||
* all the way out in landscape and the same value would leave portrait with dead
|
|
||||||
* space under midnight.
|
|
||||||
*/
|
|
||||||
fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
|
||||||
TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX)
|
|
||||||
TimelineScale.Compact -> 40.dp
|
|
||||||
TimelineScale.Regular -> 56.dp
|
|
||||||
TimelineScale.Comfortable -> 80.dp
|
|
||||||
is TimelineScale.Custom -> this.hourHeight
|
|
||||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
|
||||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
|
||||||
* to the hour it landed in.
|
|
||||||
*
|
|
||||||
* [hourPx] must be the hour height the column is drawing at *now* — a tap
|
|
||||||
* detector that captured it when it was installed maps taps to a pre-pinch grid
|
|
||||||
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
|
|
||||||
* to read, so it answers midnight rather than dividing by it.
|
|
||||||
*/
|
|
||||||
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
|
||||||
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
|
||||||
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
|
||||||
* height — a fixed 24dp would swallow half an hour once zoomed out and make
|
|
||||||
* short events overlap their neighbours.
|
|
||||||
*/
|
|
||||||
const val MIN_EVENT_FRACTION = 26f / 60f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Narrowest an event block may be and still wrap its title over several lines.
|
|
||||||
*
|
|
||||||
* Wrapping is driven by the block's height, so a tall block on a lane-split
|
|
||||||
* column would otherwise stack two or three characters per line — "Da/ily",
|
|
||||||
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
|
|
||||||
* clears this on any phone; a split one never does, while the day view's much
|
|
||||||
* wider columns keep wrapping even several lanes deep.
|
|
||||||
*/
|
|
||||||
val MIN_TITLE_WRAP_WIDTH = 36.dp
|
|
||||||
|
|
||||||
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
|
||||||
val FIT_DAY_MIN = 24.dp
|
|
||||||
|
|
||||||
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
|
|
||||||
val FIT_DAY_MAX = 96.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The hour height at which all 24 hours exactly fill [viewportHeight] — how far
|
|
||||||
* out a pinch can zoom.
|
|
||||||
*
|
|
||||||
* Below it there is no more day left to uncover, so the only thing shrinking
|
|
||||||
* further buys is empty space under midnight. Zooming out means "show me more of
|
|
||||||
* the day", and once the whole day is on screen that request is answered.
|
|
||||||
*/
|
|
||||||
fun fillHourHeight(viewportHeight: Dp): Dp = viewportHeight / 24f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ceiling for a pinch, deliberately far above [TimelineScale.Comfortable]: a
|
|
||||||
* pinch is a deliberate act, so someone zooming in on a single busy afternoon
|
|
||||||
* should be allowed to go further than any preset offers.
|
|
||||||
*/
|
|
||||||
val MAX_PINCH_HOUR_HEIGHT = 240.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floor for a *stored* pinched height, which only has to stay sane enough to be
|
|
||||||
* re-clamped against whatever viewport it is later shown in. The floor a pinch
|
|
||||||
* stops at is [fillHourHeight].
|
|
||||||
*/
|
|
||||||
private val MIN_STORED_HOUR_HEIGHT = 1.dp
|
|
||||||
|
|
||||||
@get:StringRes
|
|
||||||
val TimelineScale.labelRes: Int
|
|
||||||
get() = when (this) {
|
|
||||||
TimelineScale.FitDay -> R.string.timeline_scale_fit_day
|
|
||||||
TimelineScale.Compact -> R.string.timeline_scale_compact
|
|
||||||
TimelineScale.Regular -> R.string.timeline_scale_regular
|
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
|
||||||
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:StringRes
|
|
||||||
val TimelineScale.descriptionRes: Int
|
|
||||||
get() = when (this) {
|
|
||||||
TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary
|
|
||||||
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
|
|
||||||
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
|
||||||
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Marks a stored custom height; the rest of the value is its dp. */
|
|
||||||
private const val CUSTOM_PREFIX = "custom:"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stored names for the presets. Spelled out rather than taken from `toString()`
|
|
||||||
* so R8 can't rename them out from under an existing install — and they match
|
|
||||||
* the enum names this used to be, so a value written before the pinch existed
|
|
||||||
* still reads back as the same preset.
|
|
||||||
*/
|
|
||||||
private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
|
|
||||||
TimelineScale.FitDay to "FitDay",
|
|
||||||
TimelineScale.Compact to "Compact",
|
|
||||||
TimelineScale.Regular to "Regular",
|
|
||||||
TimelineScale.Comfortable to "Comfortable",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
|
|
||||||
*
|
|
||||||
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
|
|
||||||
* without a stored name is a compile error here instead of a crash the first
|
|
||||||
* time someone picks it.
|
|
||||||
*/
|
|
||||||
fun TimelineScale.storageValue(): String = when (this) {
|
|
||||||
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
|
||||||
TimelineScale.FitDay,
|
|
||||||
TimelineScale.Compact,
|
|
||||||
TimelineScale.Regular,
|
|
||||||
TimelineScale.Comfortable,
|
|
||||||
-> PRESET_NAMES.getValue(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a stored scale. Anything unrecognised — a null key, a name from a
|
|
||||||
* future release, a truncated custom height — falls back to
|
|
||||||
* [TimelineScale.Regular] rather than throwing, like the other enum prefs.
|
|
||||||
*/
|
|
||||||
fun parseTimelineScale(stored: String?): TimelineScale = when {
|
|
||||||
stored == null -> TimelineScale.Regular
|
|
||||||
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
|
||||||
?.let { TimelineScale.custom(it.dp) }
|
|
||||||
?: TimelineScale.Regular
|
|
||||||
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.gestures.calculateCentroid
|
|
||||||
import androidx.compose.foundation.gestures.calculateZoom
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.math.floor
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The scale the timelines draw at right now: the stored preference, or whatever
|
|
||||||
* a pinch is currently doing to it (#56).
|
|
||||||
*
|
|
||||||
* A pinch changes the scale on every pointer frame, which the preference alone
|
|
||||||
* cannot carry — a DataStore round trip per frame would lag the gesture badly
|
|
||||||
* behind the fingers. So the live value lives here in composition state and only
|
|
||||||
* the settled result is written back, once, when the fingers lift.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
class TimelineZoom(
|
|
||||||
initial: TimelineScale,
|
|
||||||
private val persist: (TimelineScale) -> Unit,
|
|
||||||
) {
|
|
||||||
/** What the week and day timelines should draw at. */
|
|
||||||
var scale: TimelineScale by mutableStateOf(initial)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether fingers are rescaling the timeline right now. Snapshot state, so
|
|
||||||
* anything that tweens off [scale] can stand down for the gesture: the
|
|
||||||
* height changes every pointer frame, and a spring would spend the whole
|
|
||||||
* pinch chasing a target that has already moved.
|
|
||||||
*/
|
|
||||||
var isPinching: Boolean by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
|
||||||
* value is a frame or two behind the fingers there, and letting it land would
|
|
||||||
* snap the timeline back while the user is still pinching.
|
|
||||||
*/
|
|
||||||
fun adopt(stored: TimelineScale) {
|
|
||||||
if (!isPinching) scale = stored
|
|
||||||
}
|
|
||||||
|
|
||||||
fun beginPinch() {
|
|
||||||
isPinching = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pinchTo(hourHeight: Dp) {
|
|
||||||
scale = TimelineScale.custom(hourHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun endPinch() {
|
|
||||||
isPinching = false
|
|
||||||
persist(scale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The zoom the timelines read. Falls back to a detached instance — one shared
|
|
||||||
* lazy value, not one per read, or each read would hand out a fresh state — so a
|
|
||||||
* preview or test can render a timeline without the activity's provider.
|
|
||||||
*/
|
|
||||||
private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {} }
|
|
||||||
|
|
||||||
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
|
||||||
* back through [onPersist].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberTimelineZoom(
|
|
||||||
stored: TimelineScale,
|
|
||||||
onPersist: (TimelineScale) -> Unit,
|
|
||||||
): TimelineZoom {
|
|
||||||
val persist by rememberUpdatedState(onPersist)
|
|
||||||
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
|
||||||
// Picking a preset in Settings has to reach the timelines, and so does the
|
|
||||||
// stored value arriving after the first frame.
|
|
||||||
LaunchedEffect(stored) { zoom.adopt(stored) }
|
|
||||||
return zoom
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Two-finger pinch that rescales a 24-hour timeline (#56) — the gesture the
|
|
||||||
* issue actually asked for; the Settings presets stay as the accessible route to
|
|
||||||
* the same thing.
|
|
||||||
*
|
|
||||||
* Three gestures share this area, so the pinch is deliberately the fussiest
|
|
||||||
* about claiming it: nothing happens until a *second* finger is down and the
|
|
||||||
* spread has grown past [PINCH_SLOP], which leaves one-finger vertical scrolling
|
|
||||||
* and the horizontal week swipe untouched, and lets a two-finger drag still
|
|
||||||
* scroll. It watches on [PointerEventPass.Initial] because the scroll it has to
|
|
||||||
* outrank is a descendant — on the main pass the scroll would have consumed the
|
|
||||||
* drag before this ever saw it.
|
|
||||||
*
|
|
||||||
* [hourHeight] is the *resolved* height, so a pinch that starts from
|
|
||||||
* `FitDay` picks up where the viewport left it rather than jumping.
|
|
||||||
* [viewportHeight] sets how far out it can go — see [fillHourHeight].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberTimelinePinchZoom(
|
|
||||||
scrollState: ScrollState,
|
|
||||||
viewportHeight: Dp,
|
|
||||||
hourHeight: Dp,
|
|
||||||
zoom: TimelineZoom,
|
|
||||||
): Modifier {
|
|
||||||
// The gesture loop outlives any single composition, so it reads these
|
|
||||||
// through state handles rather than capturing what they were when it started.
|
|
||||||
val currentHourHeight = rememberUpdatedState(hourHeight)
|
|
||||||
val currentViewport = rememberUpdatedState(viewportHeight)
|
|
||||||
return Modifier.pointerInput(scrollState, zoom) {
|
|
||||||
awaitEachGesture {
|
|
||||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
|
||||||
var claimed = false
|
|
||||||
var slop = 1f
|
|
||||||
// Scroll the layout could not give us yet, carried to the next frame
|
|
||||||
// (see anchoredScroll).
|
|
||||||
var pending = 0f
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
|
||||||
if (event.changes.none { it.pressed }) break
|
|
||||||
if (event.changes.count { it.pressed } >= 2) {
|
|
||||||
val step = event.calculateZoom()
|
|
||||||
if (!claimed) {
|
|
||||||
slop *= step
|
|
||||||
if (abs(slop - 1f) >= PINCH_SLOP) {
|
|
||||||
claimed = true
|
|
||||||
zoom.beginPinch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (claimed) {
|
|
||||||
val old = currentHourHeight.value.toPx()
|
|
||||||
val new = pinchedHourHeightPx(
|
|
||||||
target = old * step,
|
|
||||||
fillPx = fillHourHeight(currentViewport.value).toPx(),
|
|
||||||
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
|
||||||
)
|
|
||||||
// Half a pixel, not equality: the height round-trips
|
|
||||||
// through dp and back, and an exact test would read
|
|
||||||
// the float noise that comes back as a scale change
|
|
||||||
// and feed the scroll a delta on every frame of a
|
|
||||||
// held pinch.
|
|
||||||
if (abs(new - old) >= 0.5f) {
|
|
||||||
val centroidY = event.calculateCentroid(useCurrent = true).y
|
|
||||||
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
|
|
||||||
scrollState.value
|
|
||||||
zoom.pinchTo(new.toDp())
|
|
||||||
}
|
|
||||||
pending -= scrollState.dispatchRawDelta(pending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Hold the gesture to the end once it has become a pinch:
|
|
||||||
// letting go the moment a finger lifts would turn the tail of
|
|
||||||
// a zoom into a scroll, and the taps underneath into a new event.
|
|
||||||
if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// In a finally because the gesture can also end by having its
|
|
||||||
// pointer node disposed mid-pinch — the timeline swapping out
|
|
||||||
// under the fingers. The zoom outlives that node, and a pinch
|
|
||||||
// left open would make it ignore every later Settings change.
|
|
||||||
if (claimed) zoom.endPinch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a pinch aiming at [target] px per hour actually lands: clamped between
|
|
||||||
* "the whole day fills the screen" ([fillPx]) and [maxPx], then rounded to a
|
|
||||||
* whole pixel.
|
|
||||||
*
|
|
||||||
* The rounding is not cosmetic. The hour gutter is 24 stacked boxes one hour
|
|
||||||
* tall, and each rounds its own height to whole pixels, while the hour lines and
|
|
||||||
* event blocks are drawn at the fractional height — so a height of 56.4px lays
|
|
||||||
* the labels out at 56px and leaves the 23:00 label ~9px above the line it
|
|
||||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
|
||||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
|
||||||
*
|
|
||||||
* The bounds themselves are pulled onto that grid too, each in the direction
|
|
||||||
* that keeps its own promise — up for the fill floor, so no dead space opens
|
|
||||||
* under midnight, down for the ceiling. A fractional bound would be a height the
|
|
||||||
* pinch can be held against but never actually land on, and the difference feeds
|
|
||||||
* the focal anchor a scroll correction on every frame the fingers sit still.
|
|
||||||
*/
|
|
||||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
|
||||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
|
||||||
// the two to disagree, dead space is the worse of the two failures.
|
|
||||||
target.roundToInt().toFloat()
|
|
||||||
.coerceAtMost(floor(maxPx))
|
|
||||||
.coerceAtLeast(ceil(fillPx))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
|
||||||
* hour height changes from [oldHourPx] to [newHourPx].
|
|
||||||
*
|
|
||||||
* Anchoring on the fingers is what makes a zoom feel like the day is being
|
|
||||||
* stretched rather than replaced: without it, zooming in on the evening walks
|
|
||||||
* the evening off the bottom of the screen.
|
|
||||||
*
|
|
||||||
* The caller may not get all of this at once — the content only grows on the
|
|
||||||
* next layout pass, so scrolling further down than the *current* content allows
|
|
||||||
* is refused — which is why the shortfall is carried over and re-offered.
|
|
||||||
*/
|
|
||||||
internal fun anchoredScroll(
|
|
||||||
scroll: Int,
|
|
||||||
centroidY: Float,
|
|
||||||
oldHourPx: Float,
|
|
||||||
newHourPx: Float,
|
|
||||||
): Float = ((scroll + centroidY) / oldHourPx) * newHourPx - centroidY
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far the fingers must spread or close before the pinch takes over. Small
|
|
||||||
* enough to feel immediate, wide enough that the two-finger scroll a user meant
|
|
||||||
* as a scroll stays one.
|
|
||||||
*/
|
|
||||||
private const val PINCH_SLOP = 0.08f
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
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.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
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.graphics.TransformOrigin
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The viewport a view preview pretends to be: a phone's width by the height a
|
|
||||||
* calendar view gets under the top bar. Scaling every preview from one fixed
|
|
||||||
* height keeps them comparable — each shows the same slice of screen.
|
|
||||||
*/
|
|
||||||
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
|
||||||
* into a box [height] tall, for the settings and onboarding choosers. Callers
|
|
||||||
* pass the *real* view composables, so a preview cannot drift from what it
|
|
||||||
* depicts.
|
|
||||||
*
|
|
||||||
* The [layout] below ignores the incoming constraints so the view measures at a
|
|
||||||
* phone viewport, and a [graphicsLayer] scale shrinks the result.
|
|
||||||
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
|
||||||
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun ScaledViewPreview(
|
|
||||||
height: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
|
||||||
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.clipToBounds()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
// A preview is a picture, not a control: swallow touches before the
|
|
||||||
// view's own clickables see them, and give screen readers one label
|
|
||||||
// instead of six weeks of day cells.
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
awaitPointerEventScope {
|
|
||||||
while (true) {
|
|
||||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
|
||||||
.forEach { it.consume() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clearAndSetSemantics { }
|
|
||||||
// Measure at a full phone viewport but report the scaled size, so the
|
|
||||||
// node occupies exactly what it draws.
|
|
||||||
.layout { measurable, _ ->
|
|
||||||
val fullWidth = screenWidth.roundToPx()
|
|
||||||
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
|
||||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
|
||||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
|
||||||
placeable.place(0, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
transformOrigin = TransformOrigin(0f, 0f)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Column(Modifier.fillMaxSize()) { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stage every preview picker sets its preview on, cross-fading as [selected]
|
|
||||||
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
|
||||||
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
|
||||||
* and outer radius of the grouped rows underneath so the two read as one stack.
|
|
||||||
*
|
|
||||||
* Only vertical spacing is the caller's to pass — the horizontal inset is the
|
|
||||||
* rows' own, and the alignment is the point.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun <T> ViewPreviewFrame(
|
|
||||||
selected: T,
|
|
||||||
label: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable (T) -> Unit,
|
|
||||||
) {
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = GroupedListInset)
|
|
||||||
.clip(FRAME_SHAPE)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
|
||||||
.padding(FRAME_INSET),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Crossfade(
|
|
||||||
targetState = selected,
|
|
||||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
|
||||||
label = label,
|
|
||||||
) { shown ->
|
|
||||||
// Each preview renders the real view at phone size and scales it
|
|
||||||
// down, so its own corners are square — the plate rounds them.
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(PREVIEW_SHAPE)
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
content(shown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val FRAME_INSET = 8.dp
|
|
||||||
|
|
||||||
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
|
||||||
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
|
|
||||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
|
||||||
@@ -10,19 +10,13 @@ import androidx.compose.ui.res.stringResource
|
|||||||
/**
|
/**
|
||||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||||
*
|
|
||||||
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
|
|
||||||
* views (#150), so it drops straight into an app bar's `actions` slot without a
|
|
||||||
* wrapping condition — the same way [TodayAction] handles being turned off.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ViewSwitcherPill(
|
fun ViewSwitcherPill(
|
||||||
current: CalendarView,
|
current: CalendarView,
|
||||||
cycle: List<CalendarView>,
|
|
||||||
onCycle: () -> Unit,
|
onCycle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
|
|||||||
@@ -42,11 +42,9 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -55,18 +53,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
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.customActions
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -74,29 +68,12 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.BlockTimeLabel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
@@ -110,16 +87,9 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -130,11 +100,18 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val HOUR_HEIGHT = 56.dp
|
||||||
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||||
|
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||||
|
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDay.isEmpty()) return 0.dp
|
if (allDay.isEmpty()) return 0.dp
|
||||||
val lanes = allDay.maxOf { it.lane } + 1
|
val lanes = allDay.maxOf { it.lane } + 1
|
||||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
@@ -218,7 +195,6 @@ fun DayScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
drawerState = drawerState,
|
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -243,10 +219,8 @@ fun DayScreen(
|
|||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -290,6 +264,7 @@ private fun DayContent(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -300,9 +275,11 @@ private fun DayContent(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
val maxV = scrollState.maxValue
|
||||||
// centring the range centres midday at whatever hour height is in force.
|
val target = with(density) {
|
||||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||||
|
}.coerceIn(0, maxV)
|
||||||
|
scrollState.scrollTo(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
@@ -318,67 +295,42 @@ private fun DayContent(
|
|||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
AnimatedContent(
|
||||||
// floating block inside the outgoing page.
|
targetState = state,
|
||||||
val dragController = rememberTimelineDragController()
|
modifier = modifier.then(swipeModifier),
|
||||||
val move = LocalEventMove.current
|
contentKey = { s ->
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = state,
|
|
||||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
|
||||||
contentKey = { s ->
|
|
||||||
when (s) {
|
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
|
||||||
DayUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "day-transition",
|
|
||||||
) { s ->
|
|
||||||
when (s) {
|
when (s) {
|
||||||
DayUiState.Loading -> DayLoading()
|
is DayUiState.Success -> "success-${s.date}"
|
||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is DayUiState.Failure -> "failure-${s.reason}"
|
||||||
is DayUiState.Success -> DaySuccess(
|
DayUiState.Loading -> "loading"
|
||||||
state = s,
|
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = { drop ->
|
|
||||||
val took = move?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Refused, so nothing will land: let the copy go now
|
|
||||||
// rather than hold it out for a settle that never comes.
|
|
||||||
if (took != true) dragController.release()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
|
label = "day-transition",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
DayUiState.Loading -> DayLoading()
|
||||||
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is DayUiState.Success -> DaySuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TimelineDragOverlay(dragController)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DaySuccess(
|
private fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -397,10 +349,8 @@ internal fun DaySuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,10 +362,8 @@ private fun DayTopBar(
|
|||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
@@ -423,10 +371,9 @@ private fun DayTopBar(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
Text(
|
||||||
title = formatDayTitle(date, locale, currentYear),
|
text = formatDayTitle(date, locale, currentYear),
|
||||||
currentDate = date,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
onJumpToDate = onJumpToDate,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -447,7 +394,6 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
@@ -522,13 +468,11 @@ private fun AllDayBar(
|
|||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
val titleOverflow = eventTitleOverflow()
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = TextOverflow.Ellipsis,
|
||||||
softWrap = titleOverflow.softWrap,
|
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -538,73 +482,66 @@ private fun AllDayBar(
|
|||||||
private fun Timeline(
|
private fun Timeline(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val zoom = LocalTimelineZoom.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val density = LocalDensity.current
|
val locale = currentLocale()
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// timeline's own viewport height, which is only known here — below the top
|
|
||||||
// bar, date header and all-day strip.
|
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
|
||||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
|
||||||
val totalHeight = hourHeight * 24
|
|
||||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
|
||||||
// outrank the vertical scroll, and it does that by watching the initial
|
|
||||||
// pass, which only reaches it if it is their ancestor.
|
|
||||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// soft corners are permanent at any scroll position.
|
||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
HourGutter(
|
Column(
|
||||||
scrollState = scrollState,
|
modifier = Modifier
|
||||||
hourHeight = hourHeight,
|
.width(GUTTER_WIDTH)
|
||||||
dragController = dragController,
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
)
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
(0 until 24).forEach { h ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(HOUR_HEIGHT),
|
||||||
|
) {
|
||||||
|
if (h > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = (-6).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState),
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
|
||||||
) {
|
) {
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = state.date,
|
date = state.date,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
hourHeight = hourHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight)
|
.height(totalHeight),
|
||||||
// The scrolling content itself, so its root position
|
|
||||||
// already folds in the scroll offset.
|
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
dragController.geometry.let {
|
|
||||||
it.grid = coords
|
|
||||||
it.scroll = scrollState
|
|
||||||
it.hourPx = with(density) { hourHeight.toPx() }
|
|
||||||
it.columnGapPx = 0f
|
|
||||||
it.columnWidthPx = coords.size.width.toFloat()
|
|
||||||
it.days = listOf(state.date)
|
|
||||||
it.isRtl = isRtl
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,26 +554,13 @@ private fun DayColumnCard(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
hourHeight: Dp,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
// Tells a settled drop when this column has caught up with it.
|
|
||||||
LaunchedEffect(blocks, dragController.settling) {
|
|
||||||
dragController.noteGrid(date, blocks)
|
|
||||||
}
|
|
||||||
// The tap detector outlives the composition that installed it — a pinch or a
|
|
||||||
// Settings change moves the hour height without restarting it — so it reads
|
|
||||||
// the height and the callback through state handles instead of capturing
|
|
||||||
// them, or taps land on the scale the column had before the zoom (#148).
|
|
||||||
val currentHourPx = rememberUpdatedState(hourPx)
|
|
||||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -657,51 +581,31 @@ private fun DayColumnCard(
|
|||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
currentOnCreateAt.value(
|
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||||
date,
|
onCreateAt(date, hour * 60)
|
||||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
|
||||||
// Keyed by event, so a block that changes time or lane is the *same*
|
|
||||||
// composable afterwards and tweens there. The ordinal disambiguates
|
|
||||||
// a column holding two occurrences of one series.
|
|
||||||
val ordinals = mutableMapOf<Long, Int>()
|
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
val laneWidth = colWidth / block.laneCount
|
||||||
key(block.event.eventId, ordinal) {
|
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||||
val laneWidth = colWidth / block.laneCount
|
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||||
val top = hourHeight * (block.startMin / 60f)
|
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
EventBlock(
|
||||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
block = block,
|
||||||
val place = animatedBlockPlacement(
|
dark = dark,
|
||||||
x = laneWidth * block.lane,
|
onClick = { onEventClick(block.event) },
|
||||||
y = top,
|
modifier = Modifier
|
||||||
width = laneWidth,
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
height = height,
|
.width(laneWidth)
|
||||||
)
|
.height(height)
|
||||||
EventBlock(
|
.padding(horizontal = 1.dp),
|
||||||
block = block,
|
)
|
||||||
dark = dark,
|
|
||||||
height = place.height,
|
|
||||||
date = date,
|
|
||||||
dragController = dragController,
|
|
||||||
onClick = { onEventClick(block.event) },
|
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.offset(x = place.x, y = place.y)
|
|
||||||
.width(place.width)
|
|
||||||
.height(place.height)
|
|
||||||
.padding(horizontal = 1.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Current-time line, on top of the events, only on today's column.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = hourHeight)
|
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,11 +615,7 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
|
||||||
date: LocalDate,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -723,68 +623,31 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val density = LocalDensity.current
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
val titleLineHeight = with(density) {
|
|
||||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
|
||||||
}
|
|
||||||
val timeLineHeight = with(density) {
|
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
|
||||||
}
|
|
||||||
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
|
||||||
// that cannot afford both lines spends its space on the title, and one too
|
|
||||||
// short even for that drops the title rather than serving a sliced one.
|
|
||||||
val available = height - 4.dp
|
|
||||||
val showTime = block.endMin - block.startMin >= 45 &&
|
|
||||||
available >= titleLineHeight + timeLineHeight
|
|
||||||
val showTitle = available >= titleLineHeight
|
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val moveAction = eventMoveAction(block.event)
|
|
||||||
// A block clipped at the top continues from the previous day: its top edge
|
|
||||||
// is midnight, not the event's start, so dragging it would invent a time.
|
|
||||||
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
|
||||||
val dragModifier = rememberEventDragSource(
|
|
||||||
enabled = draggable,
|
|
||||||
key = block.event.instanceId,
|
|
||||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
|
||||||
onMove = dragController::move,
|
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
|
||||||
onCancel = dragController::cancel,
|
|
||||||
)
|
|
||||||
val lifted = draggable && dragController.ghosts(block, date)
|
|
||||||
val ghost = ghostAlpha(lifted)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
|
||||||
// the tap still works, since a drag consumes the up.
|
|
||||||
.then(dragModifier)
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics {
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
contentDescription = "$title, $timeLabel"
|
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
Text(
|
||||||
val titleOverflow = eventTitleOverflow(singleLine = showTime)
|
text = title,
|
||||||
Text(
|
style = MaterialTheme.typography.labelMedium,
|
||||||
text = title,
|
maxLines = if (showTime) 1 else 2,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
overflow = TextOverflow.Ellipsis,
|
||||||
maxLines = if (showTime) 1 else 2,
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
overflow = titleOverflow.overflow,
|
)
|
||||||
softWrap = titleOverflow.softWrap,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
BlockTimeLabel(
|
Text(
|
||||||
label = timeLabel,
|
text = timeLabel,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = eventInk(fill, alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -793,22 +656,17 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayLoading() {
|
private fun DayLoading() {
|
||||||
val scale = LocalTimelineZoom.current.scale
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
// doesn't resize the moment the real day arrives.
|
Box(
|
||||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
modifier = Modifier
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
.weight(1f)
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
.height(totalHeight)
|
||||||
Box(
|
.padding(horizontal = 2.dp)
|
||||||
modifier = Modifier
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
.weight(1f)
|
)
|
||||||
.height(totalHeight)
|
|
||||||
.padding(horizontal = 2.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
|
||||||
* real; only the events are stand-ins.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun DayViewPreview(
|
|
||||||
height: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
|
||||||
val state = remember(today, zone) { sampleDayState(today, zone) }
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
|
||||||
// Half the scroll range is noon — the same centring the live view does.
|
|
||||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
|
||||||
DaySuccess(
|
|
||||||
state = state,
|
|
||||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = state.allDayStripHeight(),
|
|
||||||
dragController = rememberTimelineDragController(),
|
|
||||||
onEventClick = {},
|
|
||||||
onCreateAt = { _, _ -> },
|
|
||||||
onDrop = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sample day state, laid out through the same helpers the live view uses. */
|
|
||||||
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
|
||||||
val days = listOf(today)
|
|
||||||
val events = sampleTimelineEvents(days, today, zone)
|
|
||||||
return DayUiState.Success(
|
|
||||||
date = today,
|
|
||||||
today = today,
|
|
||||||
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
|
||||||
timed = layoutDay(events, today, zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -100,7 +100,6 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -387,7 +386,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val locale = currentDetailLocale()
|
val locale = currentDetailLocale()
|
||||||
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@@ -127,14 +127,12 @@ 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.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -453,11 +451,30 @@ private fun SaveScopeDialog(
|
|||||||
onSelect: (RecurringWriteScope) -> Unit,
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
RecurringScopeDialog(
|
AlertDialog(
|
||||||
title = stringResource(R.string.event_edit_recurring_title),
|
onDismissRequest = onDismiss,
|
||||||
onSelect = onSelect,
|
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
||||||
onDismiss = onDismiss,
|
text = {
|
||||||
allowOccurrence = !recurrenceChanged,
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (!recurrenceChanged) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_following),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +563,7 @@ private fun EventEditContent(
|
|||||||
// The accent ties the form to the detail screen's design language: the
|
// The accent ties the form to the detail screen's design language: the
|
||||||
// bar under the title takes the target calendar's colour.
|
// bar under the title takes the target calendar's colour.
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
|
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,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.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
@@ -52,38 +51,28 @@ import kotlinx.datetime.toInstant
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||||
* want different reminder handling (#49), and differ in whether they own the
|
* sources want different reminder handling (#49).
|
||||||
* event's length ([ownsDuration], #54) — one that does is never restretched to the
|
|
||||||
* default-duration setting.
|
|
||||||
*/
|
*/
|
||||||
enum class ImportSource(internal val ownsDuration: Boolean) {
|
enum class ImportSource {
|
||||||
/**
|
/**
|
||||||
* An external `ACTION_INSERT` intent that named both ends (another app/widget,
|
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||||
* e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
|
* It carries no reminders of its own, so the settings default is applied
|
||||||
* own, so the settings default is applied automatically, exactly like an
|
* automatically, exactly like an in-app new event.
|
||||||
* in-app new event; its span, though, is the caller's.
|
|
||||||
*/
|
*/
|
||||||
Insert(ownsDuration = true),
|
Insert,
|
||||||
|
|
||||||
/**
|
|
||||||
* An `ACTION_INSERT` that named only a start. Reminders behave as for
|
|
||||||
* [Insert], but the length is ours to pick, so the default-duration setting
|
|
||||||
* applies just like an in-app new event.
|
|
||||||
*/
|
|
||||||
InsertOpenEnded(ownsDuration = false),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||||
* rather than silently applied or suppressed. Its span is the file's.
|
* rather than silently applied or suppressed.
|
||||||
*/
|
*/
|
||||||
File(ownsDuration = true),
|
File,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,9 +113,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
// Same freeze for the default duration (#54): once the user has set an end
|
|
||||||
// time by hand, switching calendars no longer stretches the event.
|
|
||||||
private val _durationTouched = MutableStateFlow(false)
|
|
||||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
@@ -296,10 +282,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||||
* in the day/week grid; without it the default is the next full hour (today)
|
* in the day/week grid; without it the default is the next full hour (today)
|
||||||
* or 09:00 (any other day). The event opens an hour long and is stretched to
|
* or 09:00 (any other day). No-op when a form is already open, so user input
|
||||||
* the configured default by [applyDefaultDuration], like the reminders.
|
* survives configuration changes; [reset] clears it when the screen closes.
|
||||||
* No-op when a form is already open, so user input survives configuration
|
|
||||||
* changes; [reset] clears it when the screen closes.
|
|
||||||
*/
|
*/
|
||||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||||
if (_form.value != null) return
|
if (_form.value != null) return
|
||||||
@@ -316,11 +300,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
.toLocalDateTime(zone)
|
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
applyDefaultDuration()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,12 +321,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* settings default is configured and differs, [importReminderPrompt] offers
|
* settings default is configured and differs, [importReminderPrompt] offers
|
||||||
* to swap it in rather than silently deciding for the user.
|
* to swap it in rather than silently deciding for the user.
|
||||||
*
|
*
|
||||||
* The length is frozen the same way: a source that named the event's end
|
|
||||||
* ([ImportSource.ownsDuration]) keeps it, so the default-duration setting
|
|
||||||
* never silently rewrites a span someone else chose; only an
|
|
||||||
* [ImportSource.InsertOpenEnded] intent — a start with no end — gets the
|
|
||||||
* default applied.
|
|
||||||
*
|
|
||||||
* No-op when a form is already open, so the prefill survives configuration
|
* No-op when a form is already open, so the prefill survives configuration
|
||||||
* changes.
|
* changes.
|
||||||
*/
|
*/
|
||||||
@@ -352,9 +328,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
|
|
||||||
when (source) {
|
when (source) {
|
||||||
ImportSource.Insert, ImportSource.InsertOpenEnded ->
|
ImportSource.Insert ->
|
||||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
else applyDefaultReminder()
|
else applyDefaultReminder()
|
||||||
|
|
||||||
@@ -383,9 +358,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||||
// Write through update(): the duration default resolves in parallel,
|
_form.value = form.copy(reminders = reminders)
|
||||||
// so both must compose onto the current form, not a pre-suspend copy.
|
|
||||||
update { it.copy(reminders = reminders) }
|
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
if (reminders.isNotEmpty()) {
|
if (reminders.isNotEmpty()) {
|
||||||
@@ -404,39 +377,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
}.first()
|
}.first()
|
||||||
|
|
||||||
/**
|
|
||||||
* Stretch a new timed event to the configured default length (#54) — the
|
|
||||||
* resolved calendar's per-calendar duration, otherwise the global default —
|
|
||||||
* keeping its start put. No-op while editing an existing event, on an all-day
|
|
||||||
* event (which is date-anchored), or once the user has set an end time by
|
|
||||||
* hand. [calendarId] short-circuits the resolution after a calendar switch;
|
|
||||||
* null resolves it as the form does.
|
|
||||||
*/
|
|
||||||
private fun applyDefaultDuration(calendarId: Long? = null) {
|
|
||||||
if (_editTarget.value != null || _durationTouched.value) return
|
|
||||||
viewModelScope.launch {
|
|
||||||
val global = settingsPrefs.defaultEventDurationMinutes.first()
|
|
||||||
val overrides = settingsPrefs.perCalendarEventDuration.first()
|
|
||||||
// Only resolve the calendar when an override could actually apply —
|
|
||||||
// that read waits on the provider query, and the stretch would then
|
|
||||||
// land after the user can already see (and accept) the end time.
|
|
||||||
val targetId = when {
|
|
||||||
overrides.isEmpty() -> null
|
|
||||||
else -> calendarId ?: resolvedCalendarId.first()
|
|
||||||
}
|
|
||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
|
||||||
if (_editTarget.value != null || _durationTouched.value) return@launch
|
|
||||||
if (_form.value?.isAllDay != false) return@launch
|
|
||||||
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
|
||||||
val zone = TimeZone.currentSystemDefault()
|
|
||||||
update { form ->
|
|
||||||
form.copy(
|
|
||||||
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||||
* file often has none while the user still expects their configured default.
|
* file often has none while the user still expects their configured default.
|
||||||
@@ -515,7 +455,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
_durationTouched.value = false
|
|
||||||
_importReminderPrompt.value = null
|
_importReminderPrompt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,9 +479,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// The default reminder differs for all-day vs timed; re-apply the
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
// Coming back out of all-day re-applies the default length (guarded);
|
|
||||||
// going all-day is a no-op, since dates carry no duration.
|
|
||||||
applyDefaultDuration()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -564,10 +500,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) {
|
fun setCalendar(id: Long) {
|
||||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
// A fresh event re-inherits the new calendar's default reminder and
|
// A fresh event re-inherits the new calendar's default reminder unless
|
||||||
// length unless the user has already hand-edited them (guarded inside).
|
// the user has already hand-edited it (guarded inside).
|
||||||
applyDefaultReminder(id)
|
applyDefaultReminder(id)
|
||||||
applyDefaultDuration(id)
|
|
||||||
}
|
}
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
@@ -624,18 +559,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
/** Moving the start drags the end along, preserving the duration. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
|
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||||
// Setting an end by hand is the user picking a length: it freezes the
|
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||||
// default duration, so a later calendar switch keeps their span (#54).
|
|
||||||
fun setEndDate(date: LocalDate) {
|
|
||||||
_durationTouched.value = true
|
|
||||||
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setEndTime(time: LocalTime) {
|
|
||||||
_durationTouched.value = true
|
|
||||||
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and write. Saving a dirty recurring event pauses in
|
* Validate and write. Saving a dirty recurring event pauses in
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ 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.calendula.ui.common.sourceAppName
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
@@ -74,12 +73,7 @@ private fun FilterList(
|
|||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
start = GroupedListInset,
|
|
||||||
end = GroupedListInset,
|
|
||||||
top = 12.dp,
|
|
||||||
bottom = 4.dp,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -109,7 +103,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
|||||||
repeat(4) {
|
repeat(4) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = GroupedListInset)
|
.padding(horizontal = 28.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.background(
|
.background(
|
||||||
@@ -135,6 +129,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = GroupedListInset, vertical = 24.dp),
|
.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/** One week row's live geometry, republished on every layout while it is on screen. */
|
|
||||||
class MonthRowGeometry(
|
|
||||||
val days: List<LocalDate>,
|
|
||||||
/** The row's day-column box — the space chip offsets are measured in. */
|
|
||||||
val cell: LayoutCoordinates,
|
|
||||||
/** The lane band inside that box, where the chips themselves are seated. */
|
|
||||||
val band: LayoutCoordinates?,
|
|
||||||
val columnWidthPx: Float,
|
|
||||||
val laneHeightPx: Float,
|
|
||||||
val laneCount: Int,
|
|
||||||
/**
|
|
||||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
|
||||||
* never mirrored but the grid is, so the mapping has to flip with it.
|
|
||||||
*/
|
|
||||||
val isRtl: Boolean,
|
|
||||||
/** What this row currently draws in [lane] of column `col`, or null. */
|
|
||||||
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
|
||||||
) {
|
|
||||||
/** Root top-left of the chip seated in [lane] of column [col]. */
|
|
||||||
fun seat(col: Int, lane: Int): Offset? {
|
|
||||||
val band = band?.takeIf { it.isAttached } ?: return null
|
|
||||||
val origin = band.positionInRoot()
|
|
||||||
val column = if (isRtl) days.lastIndex - col else col
|
|
||||||
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where this row seats [event] on [date], or null when it doesn't hold it —
|
|
||||||
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
|
||||||
*/
|
|
||||||
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
|
||||||
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
|
||||||
val lane = (0 until laneCount).firstOrNull { lane ->
|
|
||||||
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
|
||||||
} ?: return null
|
|
||||||
return seat(col, lane)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
|
||||||
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
|
||||||
* move writes a new event id — the title survives both.
|
|
||||||
*/
|
|
||||||
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
|
||||||
chip.eventId == moved.eventId || chip.title == moved.title
|
|
||||||
|
|
||||||
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
|
||||||
data class MonthChipDrag(
|
|
||||||
val event: EventInstance,
|
|
||||||
/** The day whose column the chip was grabbed in — the drag is a delta from it. */
|
|
||||||
val grabDate: LocalDate,
|
|
||||||
val targetDate: LocalDate?,
|
|
||||||
val topLeftInRoot: Offset,
|
|
||||||
val sizePx: IntSize,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
|
||||||
data class MonthChipDrop(
|
|
||||||
val event: EventInstance,
|
|
||||||
val grabDate: LocalDate,
|
|
||||||
val targetDate: LocalDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hoisted drag state for the month grid (#68). Every visible week row registers
|
|
||||||
* itself so a chip can be carried across rows, the target resolved against
|
|
||||||
* whichever registered row the finger is over. Rows are keyed by an identity
|
|
||||||
* token rather than a date: the continuous style can show the same week twice.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
class MonthDragController {
|
|
||||||
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
|
|
||||||
|
|
||||||
var drag: MonthChipDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A dropped chip, held on the day it landed on while the write runs — the
|
|
||||||
* grid behind it still shows the old day until the query re-reads.
|
|
||||||
*/
|
|
||||||
var settling: MonthChipDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
|
||||||
* [drag], which changes every frame, so the chips and rows that only need
|
|
||||||
* "am I the ghost" don't recompose that often. Stays set through [settling].
|
|
||||||
*/
|
|
||||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the grid itself now draws the drop, in root coordinates — published
|
|
||||||
* by whichever week row ends up holding it. The copy is dropped wherever the
|
|
||||||
* finger was while the grid seats it in a lane, so without this it would
|
|
||||||
* cross-fade across the gap instead of gliding onto its seat.
|
|
||||||
*/
|
|
||||||
var settledInRoot: Offset? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
|
||||||
* [handOver] rather than [release], or the chip stays dim under a copy that
|
|
||||||
* has already faded and brightens afterwards: a visible dip.
|
|
||||||
*/
|
|
||||||
private var settledGhost: Boolean by mutableStateOf(false)
|
|
||||||
|
|
||||||
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
|
||||||
var isDragging: Boolean by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last drop this controller made, kept past [release] for [beginUndo] —
|
|
||||||
* the confirmation chip carries Undo long after the copy is gone.
|
|
||||||
*/
|
|
||||||
private var undoable: MonthChipDrag? = null
|
|
||||||
|
|
||||||
private var event: EventInstance? = null
|
|
||||||
private var grabDate: LocalDate? = null
|
|
||||||
private var grab = Offset.Zero
|
|
||||||
private var pointer = Offset.Zero
|
|
||||||
private var sizePx = IntSize.Zero
|
|
||||||
|
|
||||||
fun putRow(token: Any, geometry: MonthRowGeometry) {
|
|
||||||
rows[token] = geometry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeRow(token: Any) {
|
|
||||||
rows.remove(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun begin(
|
|
||||||
event: EventInstance,
|
|
||||||
grabDate: LocalDate,
|
|
||||||
pointerInRoot: Offset,
|
|
||||||
chipInRoot: Offset,
|
|
||||||
size: IntSize,
|
|
||||||
) {
|
|
||||||
this.event = event
|
|
||||||
this.grabDate = grabDate
|
|
||||||
settling = null
|
|
||||||
isDragging = true
|
|
||||||
liftedInstanceId = event.instanceId
|
|
||||||
grab = pointerInRoot - chipInRoot
|
|
||||||
pointer = pointerInRoot
|
|
||||||
sizePx = size
|
|
||||||
recompute()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun move(pointerInRoot: Offset) {
|
|
||||||
pointer = pointerInRoot
|
|
||||||
recompute()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
event = null
|
|
||||||
grabDate = null
|
|
||||||
isDragging = false
|
|
||||||
liftedInstanceId = null
|
|
||||||
drag = null
|
|
||||||
settling = null
|
|
||||||
settledInRoot = null
|
|
||||||
settledGhost = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
|
||||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
|
||||||
*/
|
|
||||||
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
|
||||||
val landed = settling?.takeIf { settledGhost } ?: return false
|
|
||||||
if (days == null || landed.targetDate !in days) return false
|
|
||||||
return isSameEvent(event, landed.event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask the row keyed [token] whether it now seats the settled chip. Both
|
|
||||||
* copies of a week the continuous style shows twice will answer; the seat
|
|
||||||
* nearest where the chip was let go is the one the finger was over.
|
|
||||||
*/
|
|
||||||
fun noteSettled(token: Any) {
|
|
||||||
val landed = settling ?: return
|
|
||||||
val target = landed.targetDate ?: return
|
|
||||||
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
|
||||||
val current = settledInRoot
|
|
||||||
val closer = current == null ||
|
|
||||||
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
|
||||||
if (closer) settledInRoot = at
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put a chip on the journey an undo's inverse write is about to make, so it
|
|
||||||
* travels back rather than teleporting. Writes nothing itself. False when
|
|
||||||
* there is no drop of this controller's left to undo, or the grid doesn't
|
|
||||||
* seat the moved event where the chip would have to start from.
|
|
||||||
*/
|
|
||||||
fun beginUndo(): Boolean {
|
|
||||||
val last = undoable ?: return false
|
|
||||||
undoable = null
|
|
||||||
val from = last.targetDate ?: return false
|
|
||||||
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
|
||||||
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
|
||||||
liftedInstanceId = last.event.instanceId
|
|
||||||
settledGhost = true
|
|
||||||
settledInRoot = null
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End the drag, handing back where it landed — null when it never resolved,
|
|
||||||
* or when it landed back on the day it came from. A real drop keeps its chip
|
|
||||||
* on the target day as [settling] until [release], slid over to that day's
|
|
||||||
* column so it lands where the grid is about to draw it.
|
|
||||||
*/
|
|
||||||
fun finish(): MonthChipDrop? {
|
|
||||||
val landed = drag
|
|
||||||
val left = landed?.targetDate?.let(::columnLeft)
|
|
||||||
cancel()
|
|
||||||
val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null
|
|
||||||
settling = landed.copy(
|
|
||||||
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
|
||||||
)
|
|
||||||
liftedInstanceId = landed.event.instanceId
|
|
||||||
settledGhost = true
|
|
||||||
undoable = settling
|
|
||||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop ghosting the source while the copy is still dissolving — un-ghosting
|
|
||||||
* at [release] instead leaves the chip dim and then brightens it.
|
|
||||||
*/
|
|
||||||
fun handOver() {
|
|
||||||
liftedInstanceId = null
|
|
||||||
settledGhost = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the copy, once it has faded into the grid's own chip. */
|
|
||||||
fun release() {
|
|
||||||
settling = null
|
|
||||||
settledInRoot = null
|
|
||||||
handOver()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Root x of [date]'s column, in whichever visible row shows that day. */
|
|
||||||
private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row ->
|
|
||||||
val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null
|
|
||||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
|
||||||
?: return@firstNotNullOfOrNull null
|
|
||||||
val column = if (row.isRtl) row.days.lastIndex - index else index
|
|
||||||
bounds.left + column * row.columnWidthPx
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recompute() {
|
|
||||||
val event = event ?: return
|
|
||||||
val grabbed = grabDate ?: return
|
|
||||||
val resolved = rows.values.firstNotNullOfOrNull { row ->
|
|
||||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
|
||||||
?: return@firstNotNullOfOrNull null
|
|
||||||
if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
|
|
||||||
.toInt()
|
|
||||||
.coerceIn(0, row.days.lastIndex)
|
|
||||||
row.days[if (row.isRtl) row.days.lastIndex - column else column]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drag = MonthChipDrag(
|
|
||||||
event = event,
|
|
||||||
grabDate = grabbed,
|
|
||||||
// Off the grid entirely: keep the last cell it was over, so a
|
|
||||||
// wobble past the edge doesn't drop the target.
|
|
||||||
targetDate = resolved ?: drag?.targetDate,
|
|
||||||
topLeftInRoot = pointer - grab,
|
|
||||||
sizePx = sizePx,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
|
||||||
|
|
||||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
|
||||||
@@ -4,9 +4,7 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.VectorConverter
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
@@ -61,11 +59,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -81,33 +77,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.foundation.layout.absoluteOffset
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.semantics.customActions
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
@@ -131,7 +101,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -141,7 +110,6 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
@@ -156,7 +124,6 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -362,7 +329,6 @@ fun MonthScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
drawerState = drawerState,
|
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -384,13 +350,10 @@ fun MonthScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
title = topBarTitle,
|
title = topBarTitle,
|
||||||
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -416,23 +379,13 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
// Hoisted above every style's grid: a dragged chip is drawn in an
|
Column(
|
||||||
// overlay, clear of the week row's and the list viewport's clips.
|
|
||||||
val chipDrag = rememberMonthDragController()
|
|
||||||
// Undo moves the event back, so the chip travels back too. The
|
|
||||||
// signal comes from the write, not from the chip that offers it.
|
|
||||||
OnUndoStarted { chipDrag.beginUndo() }
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
LocalDimCutoff provides dimCutoff,
|
|
||||||
LocalMonthDrag provides chipDrag,
|
|
||||||
) {
|
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
ContinuousMonthContent(
|
ContinuousMonthContent(
|
||||||
state = continuousState,
|
state = continuousState,
|
||||||
@@ -469,103 +422,10 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MonthDragOverlay(chipDrag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How long to wait for a grid that never seats the drop — it landed in a day's
|
|
||||||
* "+N" overflow or on a day this month doesn't show, or the write failed.
|
|
||||||
*/
|
|
||||||
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
|
||||||
|
|
||||||
/** The chip in flight, drawn over the grid and following the finger. */
|
|
||||||
@Composable
|
|
||||||
private fun MonthDragOverlay(controller: MonthDragController) {
|
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
val moveInFlight = moveInFlight()
|
|
||||||
// Here rather than beside the controller: this reads [drag], which changes
|
|
||||||
// every frame, and this composable is the one that is meant to.
|
|
||||||
DragSnapHaptics(controller.drag?.targetDate)
|
|
||||||
// Hold the landed chip until the write is done — including the time a
|
|
||||||
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
|
||||||
// it to its seat: it was dropped at whatever height the finger was at, while
|
|
||||||
// the grid seats it in a lane.
|
|
||||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
|
||||||
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
|
||||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
|
||||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
|
||||||
val seat = controller.settledInRoot
|
|
||||||
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
|
||||||
val landed = controller.settling
|
|
||||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
|
||||||
if (seat == null) {
|
|
||||||
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
|
||||||
} else {
|
|
||||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
|
||||||
gliding = true
|
|
||||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
|
||||||
delay(SETTLE_GRACE_MILLIS)
|
|
||||||
}
|
|
||||||
handingOver = true
|
|
||||||
controller.handOver()
|
|
||||||
delay(SETTLE_FADE_MILLIS.toLong())
|
|
||||||
controller.release()
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
// A copy of a chip still in the tree behind it; announcing it again
|
|
||||||
// would duplicate the event for the drag's duration.
|
|
||||||
.clearAndSetSemantics { }
|
|
||||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
|
||||||
) {
|
|
||||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
|
||||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
|
||||||
val lift by animateFloatAsState(
|
|
||||||
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
|
|
||||||
label = "chip-lift",
|
|
||||||
)
|
|
||||||
val copyAlpha by animateFloatAsState(
|
|
||||||
targetValue = if (handingOver) 0f else 1f,
|
|
||||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
|
||||||
label = "chip-handover",
|
|
||||||
)
|
|
||||||
MonthBar(
|
|
||||||
event = drag.event,
|
|
||||||
dark = dark,
|
|
||||||
continuesLeft = false,
|
|
||||||
continuesRight = false,
|
|
||||||
modifier = Modifier
|
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
|
||||||
.absoluteOffset {
|
|
||||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
|
||||||
IntOffset(
|
|
||||||
(at.x - origin.x).roundToInt(),
|
|
||||||
(at.y - origin.y).roundToInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.width(with(density) { drag.sizePx.width.toDp() })
|
|
||||||
.height(with(density) { drag.sizePx.height.toDp() })
|
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = 1f + 0.04f * lift
|
|
||||||
scaleY = 1f + 0.04f * lift
|
|
||||||
shadowElevation = 8.dp.toPx() * lift
|
|
||||||
alpha = copyAlpha
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
clip = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
@@ -649,23 +509,19 @@ private fun ContinuousMonthContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
titleDate: LocalDate,
|
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
Text(
|
||||||
title = title,
|
text = title,
|
||||||
currentDate = titleDate,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
onJumpToDate = onJumpToDate,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -686,7 +542,6 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
@@ -1618,7 +1473,7 @@ private fun SplitDots(
|
|||||||
.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)
|
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventAccent(event.color, dark, soften), CircleShape),
|
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden.isNotEmpty()) {
|
if (hidden.isNotEmpty()) {
|
||||||
@@ -1857,50 +1712,6 @@ private fun MonthWeekRow(
|
|||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
val morphing = morphInFlight()
|
val morphing = morphInFlight()
|
||||||
|
|
||||||
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
|
||||||
// — the full-bleed tap layer sits on top of them — so one detector on the
|
|
||||||
// row's day-column box hit-tests them geometrically instead.
|
|
||||||
val moveScope = LocalEventMove.current
|
|
||||||
val dragController = LocalMonthDrag.current
|
|
||||||
val rowToken = remember { Any() }
|
|
||||||
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
|
||||||
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
|
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
|
||||||
val dragging = dragController?.isDragging == true
|
|
||||||
DisposableEffect(rowToken, dragController) {
|
|
||||||
onDispose { dragController?.removeRow(rowToken) }
|
|
||||||
}
|
|
||||||
// Republished on layout *and* on every recomposition: the coordinates change
|
|
||||||
// only on the former, but what the row draws — which the controller reads to
|
|
||||||
// find a moved chip's seat — changes on the latter.
|
|
||||||
val publish = {
|
|
||||||
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
|
|
||||||
if (dragController != null && cell != null) {
|
|
||||||
dragController.putRow(
|
|
||||||
rowToken,
|
|
||||||
MonthRowGeometry(
|
|
||||||
days = week.days,
|
|
||||||
cell = cell,
|
|
||||||
band = bandCoordinates[0],
|
|
||||||
columnWidthPx = cell.size.width / 7f,
|
|
||||||
laneHeightPx = rowHeightPx,
|
|
||||||
laneCount = MAX_EVENT_ROWS,
|
|
||||||
isRtl = isRtl,
|
|
||||||
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SideEffect { publish() }
|
|
||||||
// Once the grid holds the settled chip, tell the controller where this row
|
|
||||||
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
|
||||||
// which is what changes when the re-read lands.
|
|
||||||
LaunchedEffect(week, dragController?.settling) {
|
|
||||||
dragController?.noteSettled(rowToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(modifier) {
|
Row(modifier) {
|
||||||
// Optional calendar-week gutter, sized so the seven day columns below
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
// divide the remaining width — the absolute bar offsets stay correct
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
@@ -1916,21 +1727,7 @@ private fun MonthWeekRow(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight(),
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
cellCoordinates[0] = coords
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
.then(
|
|
||||||
monthChipDragModifier(
|
|
||||||
week = week,
|
|
||||||
moveScope = moveScope,
|
|
||||||
controller = dragController,
|
|
||||||
band = bandCoordinates,
|
|
||||||
rowHeightPx = rowHeightPx,
|
|
||||||
isRtl = isRtl,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
@@ -1995,13 +1792,7 @@ private fun MonthWeekRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.onGloballyPositioned {
|
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
||||||
bandCoordinates[0] = it
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
// A dragged chip travels to another row, so the clip
|
|
||||||
// yields for it as it does for a morph.
|
|
||||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
|
||||||
) {
|
) {
|
||||||
// Spanning bars on their shared lanes.
|
// Spanning bars on their shared lanes.
|
||||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||||
@@ -2011,7 +1802,6 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = span.continuesLeft,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = span.continuesRight,
|
continuesRight = span.continuesRight,
|
||||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * span.startCol,
|
x = colW * span.startCol,
|
||||||
@@ -2081,7 +1871,6 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
days = listOf(d),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * col,
|
||||||
@@ -2162,73 +1951,6 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The row-level pickup for month chips: resolves which chip the press landed on
|
|
||||||
* from the geometry the row just laid out, and abandons the gesture on empty
|
|
||||||
* space (or an unmovable calendar) so tapping a day still opens it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun monthChipDragModifier(
|
|
||||||
week: MonthWeek,
|
|
||||||
moveScope: EventMoveScope?,
|
|
||||||
controller: MonthDragController?,
|
|
||||||
band: Array<LayoutCoordinates?>,
|
|
||||||
rowHeightPx: Float,
|
|
||||||
isRtl: Boolean,
|
|
||||||
): Modifier = rememberDragSurface(
|
|
||||||
enabled = moveScope?.dragEnabled == true && controller != null,
|
|
||||||
key = week.days.first(),
|
|
||||||
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
|
|
||||||
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
|
|
||||||
val columnPx = size.width / 7f
|
|
||||||
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
|
|
||||||
val lane = (bandY / rowHeightPx).toInt()
|
|
||||||
// The column under the finger on screen, and the day that column shows.
|
|
||||||
val column = (local.x / columnPx).toInt()
|
|
||||||
val dayIndex = if (isRtl) week.days.lastIndex - column else column
|
|
||||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
|
||||||
}
|
|
||||||
if (event == null || moveScope?.allows(event) != true) {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
controller?.begin(
|
|
||||||
event = event,
|
|
||||||
grabDate = week.days[dayIndex],
|
|
||||||
pointerInRoot = pointerInRoot,
|
|
||||||
chipInRoot = Offset(
|
|
||||||
x = nodeInRoot.x + column * columnPx,
|
|
||||||
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
|
||||||
),
|
|
||||||
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
|
||||||
)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMove = { controller?.move(it) },
|
|
||||||
onDrop = {
|
|
||||||
controller?.finish()?.let { drop ->
|
|
||||||
// How many columns the finger crossed — grabbing the middle of a
|
|
||||||
// multi-day bar shifts by what it travelled, not to where it landed.
|
|
||||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
|
||||||
val took = moveScope?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = MoveTarget.ByDays(delta),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Refused, so nothing will land: let the copy go now rather than
|
|
||||||
// hold the chip out for a settle that can never arrive.
|
|
||||||
if (took != true) controller?.release()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel = { controller?.cancel() },
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||||
@@ -2321,24 +2043,12 @@ private fun MonthBar(
|
|||||||
continuesLeft: Boolean,
|
continuesLeft: Boolean,
|
||||||
continuesRight: Boolean,
|
continuesRight: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
/**
|
|
||||||
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
|
||||||
* has to ghost until the copy lands on it, and the instance id can't find it:
|
|
||||||
* the provider hands the re-read instance a new one.
|
|
||||||
*/
|
|
||||||
days: List<LocalDate>? = null,
|
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(event.color, dark, soften)
|
val fill = eventFill(event.color, dark, soften)
|
||||||
val moveAction = eventMoveAction(event)
|
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
|
||||||
val monthDrag = LocalMonthDrag.current
|
|
||||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
|
||||||
monthDrag?.isSettledChip(event, days) == true
|
|
||||||
val ghost = ghostAlpha(lifted)
|
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
@@ -2347,22 +2057,16 @@ private fun MonthBar(
|
|||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
|
||||||
.background(fill, shape)
|
.background(fill, shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics {
|
.semantics { contentDescription = title },
|
||||||
contentDescription = title
|
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
val titleOverflow = eventTitleOverflow()
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = TextOverflow.Ellipsis,
|
||||||
softWrap = titleOverflow.softWrap,
|
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2394,7 +2098,7 @@ private fun OverflowDots(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
.background(eventFill(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val extra = total - dots.size
|
val extra = total - dots.size
|
||||||
|
|||||||
@@ -1,31 +1,54 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||||
* and onboarding choosers.
|
* chooser.
|
||||||
|
*
|
||||||
|
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||||
|
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||||
|
* event bar's colour and every preview follows automatically. The trick is
|
||||||
|
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||||
|
* out at a full phone width and a plausible viewport height, and a
|
||||||
|
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||||
*
|
*
|
||||||
* The month, today's position and the week start are all real; only the events
|
* The month, today's position and the week start are all real; only the events
|
||||||
* are stand-ins. See [ScaledViewPreview] for how the real grid composables get
|
* are stand-ins, since the settings screen has no business querying the
|
||||||
* measured at phone size and shrunk into the card.
|
* provider for a thumbnail.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MonthStylePreview(
|
internal fun MonthStylePreview(
|
||||||
@@ -34,61 +57,108 @@ internal fun MonthStylePreview(
|
|||||||
height: Dp,
|
height: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||||
|
|
||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
Box(
|
||||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
modifier = modifier
|
||||||
when (style) {
|
.clipToBounds()
|
||||||
MonthViewStyle.Paged -> MonthGrid(
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
state = sample.month,
|
// A preview is a picture, not a control: swallow touches before the
|
||||||
showWeekNumbers = false,
|
// grid's own clickables see them, and give screen readers one label
|
||||||
onOpenDay = {},
|
// instead of six weeks of day cells.
|
||||||
)
|
.pointerInput(Unit) {
|
||||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
awaitPointerEventScope {
|
||||||
state = sample.continuous,
|
while (true) {
|
||||||
listState = rememberLazyListState(
|
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
.forEach { it.consume() }
|
||||||
monthIndexOf(YearMonth(today.year, today.month)),
|
}
|
||||||
),
|
}
|
||||||
),
|
}
|
||||||
showWeekNumbers = false,
|
.clearAndSetSemantics { }
|
||||||
onOpenDay = {},
|
// Measure the grid at a full phone viewport but report the scaled
|
||||||
)
|
// size, so the node occupies exactly what it draws.
|
||||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
//
|
||||||
state = sample.continuous,
|
// Modifier.requiredSize would be the obvious way to force the larger
|
||||||
listState = rememberLazyListState(
|
// measurement, but it *centres* content that overflows the incoming
|
||||||
// Start a week above today's, so the preview shows a stream
|
// constraints — which pushed the grid to a negative offset and left
|
||||||
// running past the viewport rather than one beginning at its
|
// only its bottom-right corner inside the clip.
|
||||||
// top edge.
|
.layout { measurable, _ ->
|
||||||
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
val fullWidth = screenWidth.roundToPx()
|
||||||
),
|
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||||
showWeekNumbers = false,
|
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||||
onOpenDay = {},
|
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||||
)
|
placeable.place(0, 0)
|
||||||
MonthViewStyle.Split -> {
|
}
|
||||||
SplitMonthGrid(
|
}
|
||||||
state = sample.month,
|
.graphicsLayer {
|
||||||
selected = today,
|
scaleX = scale
|
||||||
showWeekNumbers = false,
|
scaleY = scale
|
||||||
onSelectDay = {},
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
)
|
},
|
||||||
SplitDayPane(
|
) {
|
||||||
date = today,
|
Column(Modifier.fillMaxSize()) {
|
||||||
today = today,
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||||
events = sample.month.instancesByDay[today].orEmpty(),
|
when (style) {
|
||||||
zone = zone,
|
MonthViewStyle.Paged -> MonthGrid(
|
||||||
onOpenDay = {},
|
state = sample.month,
|
||||||
onEventClick = {},
|
showWeekNumbers = false,
|
||||||
onCreateEvent = {},
|
onOpenDay = {},
|
||||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
)
|
||||||
)
|
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||||
|
monthIndexOf(YearMonth(today.year, today.month)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
// Start a week above today's, so the preview shows a
|
||||||
|
// stream running past the viewport rather than one
|
||||||
|
// beginning at its top edge.
|
||||||
|
initialFirstVisibleItemIndex =
|
||||||
|
weekIndexOf(today, weekStart) - 1,
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Split -> {
|
||||||
|
SplitMonthGrid(
|
||||||
|
state = sample.month,
|
||||||
|
selected = today,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onSelectDay = {},
|
||||||
|
)
|
||||||
|
SplitDayPane(
|
||||||
|
date = today,
|
||||||
|
today = today,
|
||||||
|
events = sample.month.instancesByDay[today].orEmpty(),
|
||||||
|
zone = zone,
|
||||||
|
onOpenDay = {},
|
||||||
|
onEventClick = {},
|
||||||
|
onCreateEvent = {},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewport the preview pretends to be: a phone's width by the height a
|
||||||
|
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||||
|
* three styles comparable — each shows the same slice of screen.
|
||||||
|
*/
|
||||||
|
private val VIRTUAL_HEIGHT = 440.dp
|
||||||
private val SPLIT_PANE_HEIGHT = 170.dp
|
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||||
|
|
||||||
private class SampleMonth(
|
private class SampleMonth(
|
||||||
@@ -100,6 +170,9 @@ private class SampleMonth(
|
|||||||
* A month's worth of stand-in events, laid out through the same
|
* A month's worth of stand-in events, laid out through the same
|
||||||
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||||
|
*
|
||||||
|
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||||
|
* event, so a token here would misrepresent what the grid actually renders.
|
||||||
*/
|
*/
|
||||||
private fun sampleMonthState(
|
private fun sampleMonthState(
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
@@ -108,7 +181,7 @@ private fun sampleMonthState(
|
|||||||
): SampleMonth {
|
): SampleMonth {
|
||||||
val ym = YearMonth(today.year, today.month)
|
val ym = YearMonth(today.year, today.month)
|
||||||
val first = LocalDate(ym.year, ym.month, 1)
|
val first = LocalDate(ym.year, ym.month, 1)
|
||||||
val events = sampleMonthEvents(first, today, zone)
|
val events = sampleEvents(first, today, zone)
|
||||||
|
|
||||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||||
val month = MonthUiState.Success(
|
val month = MonthUiState.Success(
|
||||||
@@ -140,3 +213,72 @@ private fun sampleMonthState(
|
|||||||
)
|
)
|
||||||
return SampleMonth(month, continuous)
|
return SampleMonth(month, continuous)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_COLORS = listOf(
|
||||||
|
0xFF3F7BD4.toInt(),
|
||||||
|
0xFFCE5B4C.toInt(),
|
||||||
|
0xFF4E9A6A.toInt(),
|
||||||
|
0xFF8A63C7.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sampleEvents(
|
||||||
|
firstOfMonth: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
var id = 0L
|
||||||
|
fun next() = ++id
|
||||||
|
|
||||||
|
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
start = day.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
// All-day events sit at UTC midnights with an exclusive end.
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spread across the month so most weeks carry something, with one multi-day
|
||||||
|
// bar to show a span bridging cells and a busy today for the split pane.
|
||||||
|
return buildList {
|
||||||
|
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||||
|
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||||
|
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||||
|
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||||
|
// Today, so the split pane's list and the grid's dots both have content.
|
||||||
|
add(timed(today, 9, 1, 0))
|
||||||
|
add(timed(today, 12, 1, 1))
|
||||||
|
add(timed(today, 15, 2, 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_TITLES = listOf(
|
||||||
|
"Standup",
|
||||||
|
"Lunch",
|
||||||
|
"Review",
|
||||||
|
"Gym",
|
||||||
|
"Call",
|
||||||
|
"Workshop",
|
||||||
|
"Dentist",
|
||||||
|
"Trip",
|
||||||
|
)
|
||||||
|
|||||||
@@ -67,23 +67,6 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
|
||||||
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
|
|
||||||
* this point", so an empty lane above a seated bar has to stay empty (#68).
|
|
||||||
*/
|
|
||||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
|
||||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
|
||||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
|
||||||
val occupied = spans
|
|
||||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
|
||||||
.map { it.lane }
|
|
||||||
.toSet()
|
|
||||||
val free = (0 until laneCap).filter { it !in occupied }
|
|
||||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
|
||||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
* 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
|
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CloudOff
|
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step shown only when everything you write to is a device-only calendar
|
|
||||||
* (#163): the events exist nowhere but this phone, so losing it loses them.
|
|
||||||
*
|
|
||||||
* The primary action picks a folder and switches automatic backup on in one go.
|
|
||||||
* Everything it sets is a normal Backup setting, adjustable afterwards.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun BackupStep(
|
|
||||||
onEnable: (Uri) -> Unit,
|
|
||||||
onSkip: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
navigationIcon: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val pickFolder = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
|
||||||
) { uri ->
|
|
||||||
// Cancelling the picker decides nothing and leaves the step open.
|
|
||||||
uri?.let(onEnable)
|
|
||||||
}
|
|
||||||
|
|
||||||
OnboardingScaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
topSpacing = OnboardingSpace.lg,
|
|
||||||
hero = { IconHero(Icons.Filled.CloudOff) },
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_backup_enable_button),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = onSkip,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.onboarding_backup_skip_button))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_backup_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_backup_body),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
|
||||||
|
|
||||||
BenefitRow(
|
|
||||||
icon = Icons.Filled.FolderOpen,
|
|
||||||
title = stringResource(R.string.onboarding_backup_benefit_folder_title),
|
|
||||||
body = stringResource(R.string.onboarding_backup_benefit_folder_body),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
|
||||||
BenefitRow(
|
|
||||||
icon = Icons.Filled.Schedule,
|
|
||||||
title = stringResource(R.string.onboarding_backup_benefit_daily_title),
|
|
||||||
body = stringResource(R.string.onboarding_backup_benefit_daily_body),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The wizard's closing screen (#163): the flow ends on a full screen saying so
|
|
||||||
* rather than dropping into the calendar mid-gesture, and the brand mark wears
|
|
||||||
* the check that the last step earned.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun DoneStep(
|
|
||||||
onFinished: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
navigationIcon: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
OnboardingScaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
topSpacing = OnboardingSpace.lg,
|
|
||||||
hero = { BrandHero(HeroBadge.Done) },
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = onFinished,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_done_button),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_done_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_done_body),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
|
||||||
import androidx.compose.animation.SizeTransform
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The M3 Expressive spatial spring the wizard's steps travel on. Read in
|
|
||||||
* composable scope so it can be captured by `AnimatedContent`'s non-composable
|
|
||||||
* transitionSpec lambda.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
|
||||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moving between wizard steps (#163): the steps sit side by side and the whole
|
|
||||||
* width swipes across, position on a spring so the arriving step settles with a
|
|
||||||
* little give rather than stopping dead.
|
|
||||||
*
|
|
||||||
* No cross-fade, unlike the calendar's shared-axis paging: two steps have
|
|
||||||
* nothing in common, so fading one through the other smears. Both are opaque and
|
|
||||||
* travel in lockstep, showing one screen pushing the other off.
|
|
||||||
*
|
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back.
|
|
||||||
* @param spec spatial spec, typically [rememberOnboardingSlideSpec].
|
|
||||||
* @param fadeSpec effects spec used for the whole transition under reduced
|
|
||||||
* motion.
|
|
||||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
|
||||||
*/
|
|
||||||
internal fun onboardingSlideTransition(
|
|
||||||
slideDir: Int,
|
|
||||||
spec: FiniteAnimationSpec<IntOffset>,
|
|
||||||
fadeSpec: FiniteAnimationSpec<Float>,
|
|
||||||
reduceMotion: Boolean,
|
|
||||||
): ContentTransform {
|
|
||||||
if (reduceMotion) {
|
|
||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
|
||||||
}
|
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
|
||||||
return ContentTransform(
|
|
||||||
targetContentEnter = slideInHorizontally(spec) { w -> dir * w },
|
|
||||||
initialContentExit = slideOutHorizontally(spec) { w -> -dir * w },
|
|
||||||
// Clipping to the animating container would shear both steps against the
|
|
||||||
// viewport edge as they pass; there is no size change here to contain.
|
|
||||||
sizeTransform = SizeTransform(clip = false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
/** One screen of the first-launch wizard (#163), in the order they are shown. */
|
|
||||||
enum class OnboardingStep {
|
|
||||||
/** Required: nothing works without the calendar grant. */
|
|
||||||
Permission,
|
|
||||||
|
|
||||||
/** Whether Calendula delivers reminder notifications itself. */
|
|
||||||
Reminders,
|
|
||||||
|
|
||||||
/** Only when nothing you write to is synced anywhere — offers a backup. */
|
|
||||||
Backup,
|
|
||||||
|
|
||||||
/** Which view the app opens on, chosen from a live preview. */
|
|
||||||
View,
|
|
||||||
|
|
||||||
/** How the Month view lays itself out, chosen from a live preview. */
|
|
||||||
MonthStyle,
|
|
||||||
|
|
||||||
/** Only on an install that has calendars the device is hiding (#75). */
|
|
||||||
Visibility,
|
|
||||||
|
|
||||||
/** The closing screen: everything is answered and the app is ready. */
|
|
||||||
Done,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which steps this install owes and where it has got to. [steps] is the whole
|
|
||||||
* flow — completed steps included — so the counter doesn't renumber as steps
|
|
||||||
* are finished; [current] is null once nothing is left and the app opens.
|
|
||||||
*/
|
|
||||||
data class OnboardingPlan(
|
|
||||||
val steps: List<OnboardingStep>,
|
|
||||||
val current: OnboardingStep?,
|
|
||||||
) {
|
|
||||||
/** 1-based position of [current] in [steps], or 0 when the flow is done. */
|
|
||||||
val index: Int get() = steps.indexOf(current) + 1
|
|
||||||
|
|
||||||
val total: Int get() = steps.size
|
|
||||||
|
|
||||||
/** A one-step flow is not a wizard: there is nothing to count down. */
|
|
||||||
val showsProgress: Boolean get() = total > 1
|
|
||||||
|
|
||||||
/** The step before [current], or null at the start (and once finished). */
|
|
||||||
val previous: OnboardingStep? get() = steps.getOrNull(index - 2).takeIf { current != null }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the wizard can step back. Everything is re-askable except the
|
|
||||||
* calendar grant, which belongs to the system — once given there is nothing
|
|
||||||
* for a back press to return to.
|
|
||||||
*/
|
|
||||||
val canGoBack: Boolean get() = previous != null && previous != OnboardingStep.Permission
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Work out the flow from what is stored.
|
|
||||||
*
|
|
||||||
* The optional steps belong to installs still working through their first run.
|
|
||||||
* Before the grant that can only be guessed, so one that has not answered the
|
|
||||||
* reminder step either counts as fresh — the same condition
|
|
||||||
* [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
|
|
||||||
* commits to storage a moment later, and clears again on the closing screen so
|
|
||||||
* a later re-grant is only the permission step.
|
|
||||||
*
|
|
||||||
* [backupApplies] is null until the calendar list can be read, i.e. for the
|
|
||||||
* whole permission step; the backup step is assumed to apply until proven
|
|
||||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
|
||||||
* counter had not accounted for.
|
|
||||||
*
|
|
||||||
* The month-style step is *not* conditional on Month being the chosen view:
|
|
||||||
* Month is reachable from the drawer whatever opens first, and a step that came
|
|
||||||
* and went as the view is picked would move the counter under the user on the
|
|
||||||
* step right before it.
|
|
||||||
*
|
|
||||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
|
||||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
|
||||||
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
|
||||||
* screen.
|
|
||||||
*/
|
|
||||||
fun onboardingPlan(
|
|
||||||
hasPermission: Boolean,
|
|
||||||
remindersDone: Boolean,
|
|
||||||
wizardArmed: Boolean,
|
|
||||||
backupDone: Boolean,
|
|
||||||
viewDone: Boolean,
|
|
||||||
monthStyleDone: Boolean,
|
|
||||||
backupApplies: Boolean?,
|
|
||||||
visibilityArmed: Boolean = false,
|
|
||||||
visibilityDone: Boolean = false,
|
|
||||||
doneShown: Boolean = false,
|
|
||||||
): OnboardingPlan {
|
|
||||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
|
||||||
val steps = buildList {
|
|
||||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
|
||||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
|
||||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
|
||||||
if (fresh) {
|
|
||||||
add(OnboardingStep.View)
|
|
||||||
add(OnboardingStep.MonthStyle)
|
|
||||||
}
|
|
||||||
if (visibilityArmed) add(OnboardingStep.Visibility)
|
|
||||||
if (fresh) add(OnboardingStep.Done)
|
|
||||||
}
|
|
||||||
val current = steps.firstOrNull { step ->
|
|
||||||
when (step) {
|
|
||||||
OnboardingStep.Permission -> !hasPermission
|
|
||||||
OnboardingStep.Reminders -> !remindersDone
|
|
||||||
OnboardingStep.Backup -> !backupDone
|
|
||||||
OnboardingStep.View -> !viewDone
|
|
||||||
OnboardingStep.MonthStyle -> !monthStyleDone
|
|
||||||
OnboardingStep.Visibility -> !visibilityDone
|
|
||||||
OnboardingStep.Done -> !doneShown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return OnboardingPlan(steps, current)
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the wizard's current [step] (#163). Each step commits its own answer
|
|
||||||
* through [viewModel], which then hands the host the next one.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun OnboardingSteps(
|
|
||||||
step: OnboardingStep,
|
|
||||||
plan: OnboardingPlan,
|
|
||||||
viewModel: OnboardingViewModel,
|
|
||||||
onPermissionGranted: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
// Collected for every step, not just the two that draw a preview: starting
|
|
||||||
// it when the picker composes would leave that step blank as it slides in.
|
|
||||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// Coerced because the outgoing half of a transition may be a step the live
|
|
||||||
// plan has since dropped — the backup step goes once the calendars say it
|
|
||||||
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
|
|
||||||
val index = (plan.steps.indexOf(step) + 1).coerceAtLeast(1)
|
|
||||||
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
{
|
|
||||||
OnboardingProgress(
|
|
||||||
step = index,
|
|
||||||
total = plan.total,
|
|
||||||
label = stringResource(R.string.onboarding_step_counter, index, plan.total),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The system back gesture does the same thing as the button, and stays off
|
|
||||||
// on the first step so back still leaves the app.
|
|
||||||
BackHandler(enabled = plan.canGoBack) { viewModel.goBack() }
|
|
||||||
val navigationIcon: (@Composable () -> Unit)? = if (!plan.canGoBack) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
{
|
|
||||||
IconButton(onClick = viewModel::goBack) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.onboarding_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (step) {
|
|
||||||
OnboardingStep.Permission -> PermissionScreen(
|
|
||||||
onGranted = onPermissionGranted,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
)
|
|
||||||
OnboardingStep.Reminders -> ReminderStep(
|
|
||||||
onFinished = viewModel::finishReminders,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
OnboardingStep.Backup -> BackupStep(
|
|
||||||
onEnable = viewModel::enableAutoBackup,
|
|
||||||
onSkip = viewModel::skipBackup,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
// The choice is null only until DataStore's first emission; rendering
|
|
||||||
// nothing for that frame beats a preview built on the wrong defaults.
|
|
||||||
OnboardingStep.View -> choice?.let {
|
|
||||||
ViewStep(
|
|
||||||
choice = it,
|
|
||||||
onSelect = viewModel::setDefaultView,
|
|
||||||
onFinished = viewModel::finishView,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OnboardingStep.MonthStyle -> choice?.let {
|
|
||||||
MonthStyleStep(
|
|
||||||
choice = it,
|
|
||||||
onSelect = viewModel::setMonthViewStyle,
|
|
||||||
onFinished = viewModel::finishMonthStyle,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OnboardingStep.Visibility -> VisibilityStep(
|
|
||||||
onFinished = viewModel::finishVisibility,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
OnboardingStep.Done -> DoneStep(
|
|
||||||
onFinished = viewModel::finishOnboarding,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the first-launch wizard (#163): decides which steps this install owes
|
|
||||||
* (see [onboardingPlan]) and commits each answer.
|
|
||||||
*
|
|
||||||
* [plan] is null until the first stored emission, so the host renders nothing
|
|
||||||
* for that frame rather than flashing a step the user has already answered.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class OnboardingViewModel @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val prefs: SettingsPrefs,
|
|
||||||
private val calendarPrefs: CalendarPrefs,
|
|
||||||
private val repository: CalendarRepository,
|
|
||||||
private val scanner: ReminderScanner,
|
|
||||||
private val visibilityReconciler: CalendarVisibilityReconciler,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
/** Null until the host reports it — assuming either way would flash a screen. */
|
|
||||||
private val permissionReported = MutableStateFlow<Boolean?>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The grant as the rest of the flow sees it: published only once the wizard
|
|
||||||
* has been armed for it, since arming is a stored write and the frames
|
|
||||||
* before it lands would collapse the plan to its one-step form.
|
|
||||||
*/
|
|
||||||
private val hasPermission: StateFlow<Boolean?> = permissionReported
|
|
||||||
.mapLatest { granted ->
|
|
||||||
if (granted == true) armWizardIfFresh()
|
|
||||||
granted
|
|
||||||
}
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.Eagerly,
|
|
||||||
initialValue = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the backup step applies: something of yours is worth exporting and
|
|
||||||
* nothing you write to is synced anywhere. Null while the calendar list
|
|
||||||
* cannot be read: every frame before the grant, and any read that fails —
|
|
||||||
* an empty list is also how a null cursor arrives, and guessing false there
|
|
||||||
* would drop the step for the whole session.
|
|
||||||
*/
|
|
||||||
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
|
||||||
if (granted != true) {
|
|
||||||
flowOf(null)
|
|
||||||
} else {
|
|
||||||
repository.calendars()
|
|
||||||
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
|
||||||
.catch { emit(null) }
|
|
||||||
.flowOn(io)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val flags: Flow<OnboardingFlags> = combine(
|
|
||||||
prefs.reminderOnboardingDone,
|
|
||||||
prefs.onboardingWizardArmed,
|
|
||||||
prefs.onboardingBackupDone,
|
|
||||||
prefs.onboardingViewDone,
|
|
||||||
prefs.onboardingMonthStyleDone,
|
|
||||||
::OnboardingFlags,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The closing steps' own flags, and whether the notice applies at all. */
|
|
||||||
private val closingFlags: Flow<ClosingFlags> = combine(
|
|
||||||
calendarPrefs.visibilityNoticePending.map { it == true },
|
|
||||||
prefs.onboardingVisibilityDone,
|
|
||||||
prefs.onboardingDoneShown,
|
|
||||||
::ClosingFlags,
|
|
||||||
)
|
|
||||||
|
|
||||||
val plan: StateFlow<OnboardingPlan?> =
|
|
||||||
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
|
||||||
granted?.let {
|
|
||||||
onboardingPlan(
|
|
||||||
hasPermission = it,
|
|
||||||
remindersDone = stored.remindersDone,
|
|
||||||
wizardArmed = stored.wizardArmed,
|
|
||||||
backupDone = stored.backupDone,
|
|
||||||
viewDone = stored.viewDone,
|
|
||||||
monthStyleDone = stored.monthStyleDone,
|
|
||||||
backupApplies = backup,
|
|
||||||
visibilityArmed = closing.visibilityArmed,
|
|
||||||
visibilityDone = closing.visibilityDone,
|
|
||||||
doneShown = closing.doneShown,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The current view preferences, for the view step's initial selection. */
|
|
||||||
val viewChoice: StateFlow<ViewChoice?> = combine(
|
|
||||||
prefs.defaultView,
|
|
||||||
prefs.monthViewStyle,
|
|
||||||
prefs.weekStart,
|
|
||||||
::ViewChoice,
|
|
||||||
).stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun setHasPermission(granted: Boolean) {
|
|
||||||
permissionReported.value = granted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arm the wizard for an install that has yet to answer the reminder step,
|
|
||||||
* whatever route the grant took. Gating this on the in-app grant would
|
|
||||||
* strand anyone who granted on Android's own settings screen and came back
|
|
||||||
* to a restarted process, leaving them the reminder step alone.
|
|
||||||
*/
|
|
||||||
private suspend fun armWizardIfFresh() {
|
|
||||||
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record the in-app grant. The launch scan ran before it and bailed out
|
|
||||||
* without arming anything, so the first alarm would otherwise wait on the
|
|
||||||
* daily worker.
|
|
||||||
*/
|
|
||||||
fun onPermissionGranted() {
|
|
||||||
scanner.scanInBackground()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close the reminder step, recording whether notifications stay on. */
|
|
||||||
fun finishReminders(remindersEnabled: Boolean) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setRemindersEnabled(remindersEnabled)
|
|
||||||
prefs.setReminderOnboardingDone()
|
|
||||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
|
||||||
// alarm (#75).
|
|
||||||
scanner.scan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-ask the step before the current one, by clearing its answer. The plan
|
|
||||||
* derives the position from those answers, so un-setting one *is* the back
|
|
||||||
* navigation. The calendar grant belongs to the system and cannot be
|
|
||||||
* returned to, which [OnboardingPlan.canGoBack] already refuses.
|
|
||||||
*/
|
|
||||||
fun goBack() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (plan.value?.previous) {
|
|
||||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
|
||||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
|
||||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
|
||||||
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
|
|
||||||
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
|
|
||||||
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
|
|
||||||
OnboardingStep.Permission, null -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close the backup step without setting anything up. */
|
|
||||||
fun skipBackup() {
|
|
||||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn automatic backup on, writing to the folder the user just picked
|
|
||||||
* (taking a durable write grant so background runs can keep writing), and
|
|
||||||
* close the step.
|
|
||||||
*/
|
|
||||||
fun enableAutoBackup(folder: Uri) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching {
|
|
||||||
context.contentResolver.takePersistableUriPermission(
|
|
||||||
folder,
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
prefs.setAutoBackupFolderUri(folder.toString())
|
|
||||||
prefs.setAutoBackupEnabled(true)
|
|
||||||
BackupScheduler.apply(
|
|
||||||
context = context,
|
|
||||||
enabled = true,
|
|
||||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
|
||||||
hasFolder = true,
|
|
||||||
)
|
|
||||||
// Immediate feedback: the first backup lands while onboarding runs.
|
|
||||||
BackupScheduler.runNow(context)
|
|
||||||
prefs.setOnboardingBackupDone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDefaultView(view: CalendarView) {
|
|
||||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMonthViewStyle(style: MonthViewStyle) {
|
|
||||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close a picker step — the choice itself is saved as it is made. */
|
|
||||||
fun finishView() {
|
|
||||||
viewModelScope.launch { prefs.setOnboardingViewDone() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finishMonthStyle() {
|
|
||||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Acknowledge the calendar-visibility notice (#75). */
|
|
||||||
fun finishVisibility() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setOnboardingVisibilityDone()
|
|
||||||
// Retiring the notice drops its step, so it waits until nothing
|
|
||||||
// follows — the count must not fall on the way to a later screen.
|
|
||||||
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close the wizard from its last screen. */
|
|
||||||
fun finishOnboarding() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.finishOnboardingWizard()
|
|
||||||
retireVisibilityNotice()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put the one-time visibility notice away for good — nothing else clears it
|
|
||||||
* now that the wizard owns it, and left standing it would keep its step in
|
|
||||||
* every later plan.
|
|
||||||
*/
|
|
||||||
private suspend fun retireVisibilityNotice() {
|
|
||||||
if (calendarPrefs.visibilityNoticePending.first() == true) {
|
|
||||||
calendarPrefs.setVisibilityNoticePending(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fold the retired app-local calendar visibility into the device's own, and
|
|
||||||
* arm the notice if this install has anything to announce. Runs whenever the
|
|
||||||
* app comes up holding the permission, however it was granted — one taken on
|
|
||||||
* Android's app-settings screen never reaches the permission step's callback.
|
|
||||||
*/
|
|
||||||
fun reconcileCalendarVisibility() {
|
|
||||||
viewModelScope.launch { visibilityReconciler.run() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The stored answers the plan is derived from. */
|
|
||||||
private data class OnboardingFlags(
|
|
||||||
val remindersDone: Boolean,
|
|
||||||
val wizardArmed: Boolean,
|
|
||||||
val backupDone: Boolean,
|
|
||||||
val viewDone: Boolean,
|
|
||||||
val monthStyleDone: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
|
||||||
private data class ClosingFlags(
|
|
||||||
val visibilityArmed: Boolean,
|
|
||||||
val visibilityDone: Boolean,
|
|
||||||
val doneShown: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** What the view step starts from, and renders its previews with. */
|
|
||||||
data class ViewChoice(
|
|
||||||
val defaultView: CalendarView,
|
|
||||||
val monthViewStyle: MonthViewStyle,
|
|
||||||
val weekStart: WeekStartPref,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when this device holds events that live nowhere else: at least one local
|
|
||||||
* calendar worth exporting, and no writable synced calendar to carry them.
|
|
||||||
* A read-only subscription is not a backup, so it does not count.
|
|
||||||
*/
|
|
||||||
private fun List<CalendarSource>.backupApplies(): Boolean {
|
|
||||||
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
|
||||||
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
|
||||||
return exportable && !syncedTarget
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
|
||||||
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.ViewPreviewFrame
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step: which view the app opens on (#163). Built like the Settings
|
|
||||||
* picker it mirrors — a live preview over connected grouped rows. Selecting
|
|
||||||
* applies immediately and leaves the step open; the preview is the confirmation.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun ViewStep(
|
|
||||||
choice: ViewChoice,
|
|
||||||
onSelect: (CalendarView) -> Unit,
|
|
||||||
onFinished: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
navigationIcon: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
|
||||||
|
|
||||||
StepPickerScaffold(
|
|
||||||
title = stringResource(R.string.onboarding_view_title),
|
|
||||||
onFinished = onFinished,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
) {
|
|
||||||
StepPreview(selected = choice.defaultView) { view ->
|
|
||||||
when (view) {
|
|
||||||
CalendarView.Month -> MonthStylePreview(
|
|
||||||
style = choice.monthViewStyle,
|
|
||||||
weekStart = weekStart,
|
|
||||||
height = PREVIEW_HEIGHT,
|
|
||||||
)
|
|
||||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
|
||||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
|
||||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
|
||||||
val isSelected = view == choice.defaultView
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(view.labelRes),
|
|
||||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
|
||||||
selected = isSelected,
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = view.icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { onSelect(view) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step: how the Month view lays itself out (#163). The same shape as
|
|
||||||
* [ViewStep] and as the Settings chooser this duplicates, down to the selected
|
|
||||||
* style's own blurb sitting under its preview rather than on every row.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun MonthStyleStep(
|
|
||||||
choice: ViewChoice,
|
|
||||||
onSelect: (MonthViewStyle) -> Unit,
|
|
||||||
onFinished: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
navigationIcon: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
|
||||||
val options = MonthViewStyle.entries
|
|
||||||
|
|
||||||
StepPickerScaffold(
|
|
||||||
title = stringResource(R.string.onboarding_month_style_title),
|
|
||||||
onFinished = onFinished,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
) {
|
|
||||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
|
||||||
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
|
||||||
}
|
|
||||||
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
|
|
||||||
options.forEachIndexed { index, style ->
|
|
||||||
val isSelected = style == choice.monthViewStyle
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(style.labelRes),
|
|
||||||
position = positionOf(index, options.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { onSelect(style) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shell both picker steps share: the wizard chrome around a title and a
|
|
||||||
* full-bleed column, since grouped rows carry their own inset.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun StepPickerScaffold(
|
|
||||||
title: String,
|
|
||||||
onFinished: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
navigationIcon: (@Composable () -> Unit)? = null,
|
|
||||||
body: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
OnboardingScaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
topSpacing = OnboardingSpace.xs,
|
|
||||||
contentPadding = 0.dp,
|
|
||||||
// The preview gets the room it needs; Continue scrolls in under the
|
|
||||||
// options rather than pinning to the bottom.
|
|
||||||
scrollingActions = true,
|
|
||||||
hero = {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = GroupedListInset),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = onFinished,
|
|
||||||
// Full-bleed step: the button takes the grouped rows' own inset
|
|
||||||
// and ends up as wide as the options above it.
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = GroupedListInset)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(56.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_view_continue_button),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body = body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The live preview, framed like the picker's. The wider gap below keeps the rows
|
|
||||||
* reading as a group of their own rather than as the preview's caption.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) {
|
|
||||||
ViewPreviewFrame(
|
|
||||||
selected = selected,
|
|
||||||
label = "step-preview",
|
|
||||||
modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same size the Settings chooser gives it. */
|
|
||||||
private val PREVIEW_HEIGHT: Dp = 280.dp
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step for the one-time notice that Calendula now follows the device's
|
|
||||||
* own per-calendar visibility and some of yours are switched off (#75, #163).
|
|
||||||
*
|
|
||||||
* Nothing to decide here — the app does not switch those calendars back on,
|
|
||||||
* since that would un-hide them everywhere else too.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun VisibilityStep(
|
|
||||||
onFinished: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
navigationIcon: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
OnboardingScaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
topSpacing = OnboardingSpace.lg,
|
|
||||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = onFinished,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.onboarding_visibility_button),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_visibility_notice_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_visibility_notice_message),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -17,25 +16,20 @@ import androidx.compose.runtime.Composable
|
|||||||
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.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
/** What, if anything, the brand mark is wearing in its corner. */
|
|
||||||
internal enum class HeroBadge { Denied, Done }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||||
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||||
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||||
* A [badge] overlays the corner where the screen has a verdict to give: blocked,
|
* A lock badge overlays the corner when permission has been [denied].
|
||||||
* or finished.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BrandHero(badge: HeroBadge? = null) {
|
internal fun BrandHero(denied: Boolean) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -49,56 +43,24 @@ internal fun BrandHero(badge: HeroBadge? = null) {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (badge != null) {
|
if (denied) {
|
||||||
val container = when (badge) {
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
HeroBadge.Denied -> MaterialTheme.colorScheme.errorContainer
|
|
||||||
HeroBadge.Done -> MaterialTheme.colorScheme.primaryContainer
|
|
||||||
}
|
|
||||||
val onContainer = when (badge) {
|
|
||||||
HeroBadge.Denied -> MaterialTheme.colorScheme.onErrorContainer
|
|
||||||
HeroBadge.Done -> MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
.size(44.dp)
|
.size(44.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(container),
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = when (badge) {
|
imageVector = Icons.Filled.Lock,
|
||||||
HeroBadge.Denied -> Icons.Filled.Lock
|
|
||||||
HeroBadge.Done -> Icons.Filled.Check
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = onContainer,
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single [icon] in the brand squircle — the same silhouette as [BrandHero],
|
|
||||||
* for the wizard steps that stand for a feature rather than for the app itself.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun IconHero(icon: ImageVector) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
@@ -10,15 +10,19 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -28,11 +32,13 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
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
|
||||||
@@ -46,7 +52,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
|||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
viewModel: PermissionViewModel = hiltViewModel(),
|
viewModel: PermissionViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -71,7 +76,6 @@ fun PermissionScreen(
|
|||||||
when (state) {
|
when (state) {
|
||||||
is PermissionUiState.Rationale -> RationaleContent(
|
is PermissionUiState.Rationale -> RationaleContent(
|
||||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||||
progress = progress,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Denied -> DeniedContent(
|
is PermissionUiState.Denied -> DeniedContent(
|
||||||
@@ -79,7 +83,6 @@ fun PermissionScreen(
|
|||||||
viewModel.onRetry()
|
viewModel.onRetry()
|
||||||
launcher.launch(CALENDAR_PERMISSIONS)
|
launcher.launch(CALENDAR_PERMISSIONS)
|
||||||
},
|
},
|
||||||
progress = progress,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Granted -> {
|
is PermissionUiState.Granted -> {
|
||||||
@@ -91,14 +94,11 @@ fun PermissionScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RationaleContent(
|
private fun RationaleContent(
|
||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
progress: (@Composable () -> Unit)?,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
progress = progress,
|
hero = { BrandHero(denied = false) },
|
||||||
topSpacing = OnboardingSpace.lg,
|
|
||||||
hero = { BrandHero() },
|
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = onRequest,
|
onClick = onRequest,
|
||||||
@@ -116,8 +116,16 @@ private fun RationaleContent(
|
|||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
PrivacyFootnote()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -131,8 +139,14 @@ private fun RationaleContent(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.CalendarMonth,
|
icon = Icons.Filled.CalendarMonth,
|
||||||
title = stringResource(R.string.permission_benefit_sync_title),
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
@@ -140,9 +154,9 @@ private fun RationaleContent(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.Lock,
|
icon = Icons.Filled.VisibilityOff,
|
||||||
title = stringResource(R.string.permission_benefit_private_title),
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
body = stringResource(R.string.permission_benefit_private_body),
|
body = stringResource(R.string.permission_benefit_privacy_body),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,14 +164,12 @@ private fun RationaleContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DeniedContent(
|
private fun DeniedContent(
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
progress: (@Composable () -> Unit)?,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
progress = progress,
|
hero = { BrandHero(denied = true) },
|
||||||
hero = { BrandHero(HeroBadge.Denied) },
|
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -196,3 +208,25 @@ private fun DeniedContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrivacyFootnote() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_privacy_footnote),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
sealed interface PermissionUiState {
|
sealed interface PermissionUiState {
|
||||||
data object Rationale : PermissionUiState
|
data object Rationale : PermissionUiState
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
@@ -8,25 +8,34 @@ import android.Manifest
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.NotificationsActive
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wizard step after the calendar grant (v1.4): explains that
|
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||||
* Calendula delivers reminder notifications itself, warns about duplicates
|
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||||
* when a second calendar app has notifications on, and requests
|
* when a second calendar app has notifications on, and requests
|
||||||
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||||
@@ -36,11 +45,9 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderStep(
|
fun ReminderOnboardingScreen(
|
||||||
onFinished: (remindersEnabled: Boolean) -> Unit,
|
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
progress: (@Composable () -> Unit)? = null,
|
|
||||||
navigationIcon: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
@@ -48,10 +55,7 @@ fun ReminderStep(
|
|||||||
|
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
progress = progress,
|
hero = { BellHero() },
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
topSpacing = OnboardingSpace.lg,
|
|
||||||
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -76,6 +80,13 @@ fun ReminderStep(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.reminder_onboarding_title),
|
text = stringResource(R.string.reminder_onboarding_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -89,7 +100,7 @@ fun ReminderStep(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.NotificationsActive,
|
icon = Icons.Filled.NotificationsActive,
|
||||||
@@ -102,5 +113,30 @@ fun ReminderStep(
|
|||||||
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||||
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Tune,
|
||||||
|
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||||
|
@Composable
|
||||||
|
private fun BellHero() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.NotificationsActive,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||||
|
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||||
|
* step neither flashes for users who completed it nor gets skipped.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val scanner: ReminderScanner,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
|
.map { done -> done as Boolean? }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Close the step, recording whether reminder notifications stay on. */
|
||||||
|
fun finish(remindersEnabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||||
|
// alarm (#75).
|
||||||
|
scanner.scan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scan after the calendar permission is granted. The launch scan runs
|
||||||
|
* before the grant and bails out without arming anything, so without this
|
||||||
|
* the first alarm waits on the daily worker.
|
||||||
|
*/
|
||||||
|
fun rearmAfterGrant() {
|
||||||
|
scanner.scanInBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,108 +1,66 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.search
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.SearchOff
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
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.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
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.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
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.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
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.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
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 de.jeanlucmakiola.floret.components.SnackChip
|
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
|
||||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
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.coroutines.delay
|
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
@@ -110,13 +68,11 @@ import java.time.ZoneId
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
/** How long the delete confirmation chip stays up, matching a short snackbar. */
|
|
||||||
private const val CHIP_MILLIS = 4_000L
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-text event search over title / location / description: tap a result to
|
* Full-text event search (top-bar entry). Type a query → matching events
|
||||||
* open its detail, long-press to select several and delete them at once (#80).
|
* (title / location / description) across the whole calendar, newest-relevant
|
||||||
* A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
* first; tap a result to open its detail. A full-screen overlay hosted by
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -128,79 +84,63 @@ fun SearchScreen(
|
|||||||
) {
|
) {
|
||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val selection by viewModel.selection.collectAsStateWithLifecycle()
|
|
||||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val context = LocalContext.current
|
|
||||||
val inSelection = selection.isNotEmpty()
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// The ViewModel is activity-scoped, so a re-enter is what resets the last
|
// Each fresh open starts blank and straight into typing. The ViewModel is
|
||||||
// search, its selection and any delete receipt still up. Peeking a result
|
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
||||||
// doesn't re-run this — the screen stays composed under the detail.
|
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
||||||
|
// stays composed under the detail), so backing out keeps the query.
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.setQuery("")
|
viewModel.setQuery("")
|
||||||
viewModel.clearSelection()
|
|
||||||
viewModel.consumeDeleteResult()
|
|
||||||
}
|
|
||||||
// The field only exists out of selection mode, so a recreation that lands
|
|
||||||
// mid-selection has to wait for the clear above before it can be focused.
|
|
||||||
var focusPending by remember { mutableStateOf(true) }
|
|
||||||
LaunchedEffect(inSelection) {
|
|
||||||
if (inSelection || !focusPending) return@LaunchedEffect
|
|
||||||
focusPending = false
|
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
keyboard?.show()
|
keyboard?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
|
||||||
// holds only READ_CALENDAR, and granting continues into the held delete.
|
|
||||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) pendingWrite?.invoke()
|
|
||||||
pendingWrite = null
|
|
||||||
}
|
|
||||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
|
||||||
val granted = ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.WRITE_CALENDAR,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (granted) {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
pendingWrite = action
|
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back leaves selection mode before it leaves the screen, and without the
|
|
||||||
// predictive-back scale-out — that would read as search closing and snapping
|
|
||||||
// back. The two handlers are mutually exclusive on [inSelection].
|
|
||||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
modifier = modifier.predictiveBack(onBack = onBack),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
SearchTopBar(
|
TopAppBar(
|
||||||
inSelection = inSelection,
|
title = {
|
||||||
query = query,
|
InlineTextField(
|
||||||
selectedCount = selection.size,
|
value = query,
|
||||||
focusRequester = focusRequester,
|
onValueChange = viewModel::setQuery,
|
||||||
onQueryChange = viewModel::setQuery,
|
placeholder = stringResource(R.string.search_hint),
|
||||||
onImeAction = { keyboard?.hide() },
|
capitalization = KeyboardCapitalization.None,
|
||||||
onBack = onBack,
|
imeAction = ImeAction.Search,
|
||||||
onCloseSelection = viewModel::clearSelection,
|
onImeAction = { keyboard?.hide() },
|
||||||
onSelectAll = viewModel::selectAll,
|
modifier = Modifier
|
||||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.search_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.setQuery("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// imePadding so the idle/empty message re-centres above the keyboard and
|
// imePadding shrinks the content by the keyboard, so the centered
|
||||||
// the results list lifts clear of it.
|
// idle/empty message re-centres in the space above it (and the results
|
||||||
|
// list lifts clear of the keyboard too).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -217,413 +157,75 @@ fun SearchScreen(
|
|||||||
text = stringResource(R.string.search_empty, s.query),
|
text = stringResource(R.string.search_empty, s.query),
|
||||||
)
|
)
|
||||||
is SearchUiState.Results -> SearchResults(
|
is SearchUiState.Results -> SearchResults(
|
||||||
results = s,
|
events = s.events,
|
||||||
selection = selection,
|
|
||||||
inSelection = inSelection,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onToggle = viewModel::toggleSelection,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DeleteOutcomeChip(
|
|
||||||
deleteState = deleteState,
|
|
||||||
onConsume = viewModel::consumeDeleteResult,
|
|
||||||
modifier = Modifier.align(Alignment.BottomStart),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDeleteDialog) {
|
|
||||||
val count = selection.size
|
|
||||||
// One decision for the whole batch: the scope question is only asked when
|
|
||||||
// a recurring event is in it, and one-offs ignore whatever comes back.
|
|
||||||
if (viewModel.selectionHasRecurring()) {
|
|
||||||
RecurringScopeDialog(
|
|
||||||
title = stringResource(R.string.event_delete_recurring_title),
|
|
||||||
onSelect = { scope ->
|
|
||||||
showDeleteDialog = false
|
|
||||||
viewModel.deleteSelected(scope)
|
|
||||||
},
|
|
||||||
onDismiss = { showDeleteDialog = false },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showDeleteDialog = false },
|
|
||||||
title = {
|
|
||||||
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
|
|
||||||
},
|
|
||||||
text = { Text(stringResource(R.string.event_delete_body)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showDeleteDialog = false
|
|
||||||
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_detail_delete),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDeleteDialog = false }) {
|
|
||||||
Text(stringResource(R.string.dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The search field and the contextual selection bar as one bar: the bar stays
|
|
||||||
* in place across the swap, easing its colour and fading its slots through.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun SearchTopBar(
|
|
||||||
inSelection: Boolean,
|
|
||||||
query: String,
|
|
||||||
selectedCount: Int,
|
|
||||||
focusRequester: FocusRequester,
|
|
||||||
onQueryChange: (String) -> Unit,
|
|
||||||
onImeAction: () -> Unit,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onCloseSelection: () -> Unit,
|
|
||||||
onSelectAll: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
val container by animateColorAsState(
|
|
||||||
targetValue = if (inSelection) {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
},
|
|
||||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
label = "search-top-bar-color",
|
|
||||||
)
|
|
||||||
// Held past the selection emptying, so the count has something to draw while
|
|
||||||
// it fades out.
|
|
||||||
var lastCount by remember { mutableIntStateOf(0) }
|
|
||||||
if (selectedCount > 0) lastCount = selectedCount
|
|
||||||
|
|
||||||
// Read here so the non-composable transitionSpec lambdas can capture it.
|
|
||||||
val slotSwap = fadeThrough()
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = inSelection,
|
|
||||||
transitionSpec = { slotSwap },
|
|
||||||
label = "search-top-bar-title",
|
|
||||||
) { selecting ->
|
|
||||||
if (selecting) {
|
|
||||||
Text(
|
|
||||||
pluralStringResource(
|
|
||||||
R.plurals.search_selected_count,
|
|
||||||
lastCount,
|
|
||||||
lastCount,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
InlineTextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = onQueryChange,
|
|
||||||
placeholder = stringResource(R.string.search_hint),
|
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction = ImeAction.Search,
|
|
||||||
onImeAction = onImeAction,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRequester(focusRequester),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = inSelection,
|
|
||||||
transitionSpec = { slotSwap },
|
|
||||||
label = "search-top-bar-navigation",
|
|
||||||
) { selecting ->
|
|
||||||
if (selecting) {
|
|
||||||
IconButton(onClick = onCloseSelection) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.search_selection_close),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.search_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = inSelection,
|
|
||||||
transitionSpec = { slotSwap },
|
|
||||||
label = "search-top-bar-actions",
|
|
||||||
) { selecting ->
|
|
||||||
Row {
|
|
||||||
if (selecting) {
|
|
||||||
IconButton(onClick = onSelectAll) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.SelectAll,
|
|
||||||
contentDescription = stringResource(R.string.search_select_all),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onDelete) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Delete,
|
|
||||||
contentDescription = stringResource(
|
|
||||||
R.string.search_delete_selected,
|
|
||||||
),
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (query.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { onQueryChange("") }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.search_clear),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResults(
|
private fun SearchResults(
|
||||||
results: SearchUiState.Results,
|
events: List<EventInstance>,
|
||||||
selection: Set<Long>,
|
|
||||||
inSelection: Boolean,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onToggle: (Long) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
// No horizontal inset: GroupedRow already insets itself.
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
|
||||||
) {
|
) {
|
||||||
results.results.months.forEach { month ->
|
itemsIndexed(
|
||||||
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
|
items = events,
|
||||||
SearchSectionHeader(text = monthLabel(month))
|
key = { _, event -> event.eventId },
|
||||||
}
|
) { index, event ->
|
||||||
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
|
SearchResultRow(
|
||||||
}
|
event = event,
|
||||||
// Held back from the calendar above: matched only in the notes.
|
position = positionOf(index, events.size),
|
||||||
if (results.results.inDescriptions.isNotEmpty()) {
|
modifier = animateItemMotion(),
|
||||||
stickyHeader(key = "in-descriptions") {
|
onClick = { onEventClick(event) },
|
||||||
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
|
|
||||||
}
|
|
||||||
hitRows(
|
|
||||||
results.results.inDescriptions,
|
|
||||||
results,
|
|
||||||
selection,
|
|
||||||
inSelection,
|
|
||||||
onEventClick,
|
|
||||||
onToggle,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One section's rows, sharing a grouped-card run. */
|
|
||||||
private fun LazyListScope.hitRows(
|
|
||||||
hits: List<SearchHit>,
|
|
||||||
results: SearchUiState.Results,
|
|
||||||
selection: Set<Long>,
|
|
||||||
inSelection: Boolean,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
|
||||||
onToggle: (Long) -> Unit,
|
|
||||||
) {
|
|
||||||
itemsIndexed(
|
|
||||||
items = hits,
|
|
||||||
key = { _, hit -> hit.event.eventId },
|
|
||||||
) { index, hit ->
|
|
||||||
val event = hit.event
|
|
||||||
val deletable = results.isDeletable(event)
|
|
||||||
SearchResultRow(
|
|
||||||
hit = hit,
|
|
||||||
position = positionOf(index, hits.size),
|
|
||||||
modifier = animateItemMotion(),
|
|
||||||
selected = event.eventId in selection,
|
|
||||||
// A read-only calendar's row can't join a batch, so in selection
|
|
||||||
// mode it goes quiet rather than offering a checkbox that fails.
|
|
||||||
inSelection = inSelection,
|
|
||||||
selectable = deletable,
|
|
||||||
onClick = when {
|
|
||||||
inSelection && deletable -> ({ onToggle(event.eventId) })
|
|
||||||
inSelection -> null
|
|
||||||
else -> ({ onEventClick(event) })
|
|
||||||
},
|
|
||||||
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pinned section label, styled like the agenda's day headers. */
|
|
||||||
@Composable
|
|
||||||
private fun SearchSectionHeader(text: String) {
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "August 2026" in the locale's own field order. */
|
|
||||||
@Composable
|
|
||||||
private fun monthLabel(month: SearchMonth): String {
|
|
||||||
val locale = currentLocale()
|
|
||||||
return remember(month.year, month.monthNumber, locale) {
|
|
||||||
localizedDateFormatter(locale, "LLLLy")
|
|
||||||
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultRow(
|
private fun SearchResultRow(
|
||||||
hit: SearchHit,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selected: Boolean = false,
|
onClick: () -> Unit,
|
||||||
inSelection: Boolean = false,
|
|
||||||
selectable: Boolean = true,
|
|
||||||
onClick: (() -> Unit)?,
|
|
||||||
onLongClick: (() -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
val event = hit.event
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
// On a picked row the headline is already recoloured for the secondary
|
|
||||||
// container, so the match is carried by weight alone there.
|
|
||||||
val highlight = SpanStyle(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
// Faded like a past event anywhere else in the app — search reaches back
|
modifier = modifier,
|
||||||
// through the whole history.
|
title = event.title,
|
||||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
summary = searchSummary(event),
|
||||||
title = marked(event.title, hit.titleSpans, highlight),
|
|
||||||
summary = searchSummary(hit, highlight),
|
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
selected = selected,
|
|
||||||
dimmed = inSelection && !selectable,
|
|
||||||
leading = {
|
leading = {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(eventAccent(event.color, dark, soften)),
|
.background(eventFill(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailing = if (inSelection && selectable) {
|
|
||||||
{
|
|
||||||
Checkbox(
|
|
||||||
checked = selected,
|
|
||||||
onCheckedChange = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||||
* The batch's receipt: how many went, and whether any refused. No Undo — the
|
|
||||||
* provider can't put a deleted event back.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeleteOutcomeChip(
|
private fun searchSummary(event: EventInstance): String {
|
||||||
deleteState: BulkDeleteUiState,
|
|
||||||
onConsume: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val message = when (deleteState) {
|
|
||||||
is BulkDeleteUiState.Done -> when {
|
|
||||||
deleteState.failed > 0 -> stringResource(
|
|
||||||
R.string.search_delete_partial,
|
|
||||||
deleteState.deleted,
|
|
||||||
deleteState.failed,
|
|
||||||
)
|
|
||||||
else -> pluralStringResource(
|
|
||||||
R.plurals.search_delete_done,
|
|
||||||
deleteState.deleted,
|
|
||||||
deleteState.deleted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) {
|
|
||||||
stringResource(R.string.search_delete_denied_partial, deleteState.deleted)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.event_delete_write_denied)
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
// Held past the state being consumed so the chip has something to draw while
|
|
||||||
// it springs back out.
|
|
||||||
val shown = remember { mutableStateOf("") }
|
|
||||||
if (message != null && shown.value != message) shown.value = message
|
|
||||||
|
|
||||||
LaunchedEffect(deleteState) {
|
|
||||||
if (message == null) return@LaunchedEffect
|
|
||||||
delay(CHIP_MILLIS)
|
|
||||||
onConsume()
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
|
||||||
.height(SnackChipHeight),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
SnackChip(visible = message != null, message = shown.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [text] with every matched run emphasised. */
|
|
||||||
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
|
|
||||||
if (spans.isEmpty()) {
|
|
||||||
AnnotatedString(text)
|
|
||||||
} else {
|
|
||||||
buildAnnotatedString {
|
|
||||||
append(text)
|
|
||||||
spans.forEach { addStyle(style, it.start, it.end) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
|
|
||||||
* description when that is where the query matched.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
|
||||||
val event = hit.event
|
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val zone = remember { ZoneId.systemDefault() }
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
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 span rule, not [start]: an all-day event sits at UTC midnight and
|
// From the shared span rule, not [start]: an all-day event sits at UTC
|
||||||
// would name the day before west of UTC (#82).
|
// 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) {
|
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(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||||
@@ -634,23 +236,8 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
|
|||||||
} else {
|
} else {
|
||||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
}
|
}
|
||||||
return buildAnnotatedString {
|
val base = "$dateText · $timeText"
|
||||||
append("$dateText · $timeText")
|
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||||
event.location?.takeIf { it.isNotBlank() }?.let { location ->
|
|
||||||
append(" · ")
|
|
||||||
val offset = length
|
|
||||||
append(location)
|
|
||||||
hit.locationSpans.forEach {
|
|
||||||
addStyle(highlight, offset + it.start, offset + it.end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hit.descriptionSnippet?.let { snippet ->
|
|
||||||
append("\n")
|
|
||||||
val offset = length
|
|
||||||
append(snippet.text)
|
|
||||||
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ 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.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
@@ -18,19 +14,12 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -44,30 +33,8 @@ sealed interface SearchUiState {
|
|||||||
/** A query ran but matched nothing. */
|
/** A query ran but matched nothing. */
|
||||||
data class Empty(val query: String) : SearchUiState
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
|
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||||
data class Results(
|
data class Results(val events: List<EventInstance>) : SearchUiState
|
||||||
val results: SearchResults,
|
|
||||||
/** Rows from these are kept out of selection rather than failing later. */
|
|
||||||
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
|
||||||
) : SearchUiState {
|
|
||||||
val events: List<EventInstance> get() = results.allHits.map { it.event }
|
|
||||||
|
|
||||||
fun isDeletable(event: EventInstance): Boolean =
|
|
||||||
event.calendarId !in readOnlyCalendarIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Outcome of deleting the selected results (#80). */
|
|
||||||
sealed interface BulkDeleteUiState {
|
|
||||||
data object Idle : BulkDeleteUiState
|
|
||||||
|
|
||||||
data object Deleting : BulkDeleteUiState
|
|
||||||
|
|
||||||
/** Terminal: [deleted] events went, [failed] wouldn't. */
|
|
||||||
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
|
|
||||||
|
|
||||||
/** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */
|
|
||||||
data class NeedsPermission(val deleted: Int) : BulkDeleteUiState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@@ -80,42 +47,17 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val _query = MutableStateFlow("")
|
private val _query = MutableStateFlow("")
|
||||||
val query: StateFlow<String> = _query.asStateFlow()
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
val state: StateFlow<SearchUiState> = _query
|
||||||
private val _reload = MutableStateFlow(0)
|
.debounce(250L)
|
||||||
|
.map { it.trim() }
|
||||||
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
.distinctUntilChanged()
|
||||||
|
|
||||||
/** Event ids picked in selection mode; empty means the mode is off. */
|
|
||||||
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
|
|
||||||
|
|
||||||
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
|
|
||||||
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
|
|
||||||
|
|
||||||
val state: StateFlow<SearchUiState> = combine(
|
|
||||||
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
|
|
||||||
_reload,
|
|
||||||
) { q, _ -> q }
|
|
||||||
.mapLatest { q ->
|
.mapLatest { q ->
|
||||||
if (q.length < MIN_QUERY_LENGTH) {
|
if (q.length < MIN_QUERY_LENGTH) {
|
||||||
SearchUiState.Idle
|
SearchUiState.Idle
|
||||||
} else {
|
} else {
|
||||||
val now = Clock.System.now()
|
val results = repository.searchEvents(q)
|
||||||
val zone = TimeZone.currentSystemDefault()
|
if (results.isEmpty()) SearchUiState.Empty(q)
|
||||||
val results = EventSearch.search(
|
else SearchUiState.Results(sortNearestFirst(results))
|
||||||
candidates = repository.searchEvents(q),
|
|
||||||
query = q,
|
|
||||||
now = now,
|
|
||||||
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
|
|
||||||
zone = zone,
|
|
||||||
)
|
|
||||||
if (results.isEmpty) {
|
|
||||||
SearchUiState.Empty(q)
|
|
||||||
} else {
|
|
||||||
SearchUiState.Results(
|
|
||||||
results = results,
|
|
||||||
readOnlyCalendarIds = readOnlyCalendarIds(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(SearchUiState.Idle) }
|
.catch { emit(SearchUiState.Idle) }
|
||||||
@@ -123,94 +65,13 @@ class SearchViewModel @Inject constructor(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
if (value != _query.value) _selection.value = emptySet()
|
|
||||||
_query.value = value
|
_query.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle one result; the first pick is what turns selection mode on. */
|
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||||
fun toggleSelection(eventId: Long) {
|
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||||
val current = _selection.value
|
val now = Clock.System.now()
|
||||||
_selection.value = if (eventId in current) current - eventId else current + eventId
|
val (upcoming, past) = events.partition { it.end >= now }
|
||||||
|
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pick every result that lives in a writable calendar. */
|
|
||||||
fun selectAll() {
|
|
||||||
val results = state.value as? SearchUiState.Results ?: return
|
|
||||||
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Leave selection mode. */
|
|
||||||
fun clearSelection() {
|
|
||||||
_selection.value = emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the batch needs a [RecurringWriteScope] decision before it runs. */
|
|
||||||
fun selectionHasRecurring(): Boolean {
|
|
||||||
val results = state.value as? SearchUiState.Results ?: return false
|
|
||||||
val picked = _selection.value
|
|
||||||
return results.events.any { it.eventId in picked && it.isRecurring }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete every selected event. [scope] reaches the recurring ones only; a
|
|
||||||
* one-off always goes whole. One at a time, so a single failure doesn't take
|
|
||||||
* the rest with it — the tally lands in [deleteState].
|
|
||||||
*/
|
|
||||||
fun deleteSelected(scope: RecurringWriteScope) {
|
|
||||||
if (_deleteState.value == BulkDeleteUiState.Deleting) return
|
|
||||||
val results = state.value as? SearchUiState.Results ?: return
|
|
||||||
val picked = _selection.value
|
|
||||||
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
|
|
||||||
if (targets.isEmpty()) return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
_deleteState.value = BulkDeleteUiState.Deleting
|
|
||||||
var deleted = 0
|
|
||||||
var failed = 0
|
|
||||||
var denied = false
|
|
||||||
for (event in targets) {
|
|
||||||
try {
|
|
||||||
withContext(io) { deleteOne(event, scope) }
|
|
||||||
deleted++
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
denied = true
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_selection.value = emptySet()
|
|
||||||
_reload.value += 1
|
|
||||||
_deleteState.value = if (denied) {
|
|
||||||
BulkDeleteUiState.NeedsPermission(deleted = deleted)
|
|
||||||
} else {
|
|
||||||
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset [deleteState] after the screen showed the outcome. */
|
|
||||||
fun consumeDeleteResult() {
|
|
||||||
_deleteState.value = BulkDeleteUiState.Idle
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
|
||||||
val begin = event.start.toEpochMilliseconds()
|
|
||||||
when {
|
|
||||||
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
|
||||||
scope == RecurringWriteScope.ThisEvent ->
|
|
||||||
repository.deleteOccurrence(event.eventId, begin)
|
|
||||||
scope == RecurringWriteScope.ThisAndFollowing ->
|
|
||||||
repository.deleteEventFromOccurrence(event.eventId, begin)
|
|
||||||
else -> repository.deleteEvent(event.eventId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
|
||||||
repository.calendars().first()
|
|
||||||
.filterNot { it.canModifyContents }
|
|
||||||
.map { it.id }
|
|
||||||
.toSet()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,24 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
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.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
|
||||||
|
|
||||||
/** New event form: which fields it opens with, and how it behaves. */
|
/** New event form: which fields it opens with, and how it behaves. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -42,13 +27,6 @@ internal fun EventFormScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
|
||||||
// The calendar whose duration-override picker is open, if any.
|
|
||||||
var durationTarget by remember { mutableStateOf<Long?>(null) }
|
|
||||||
var durationSectionExpanded by remember { mutableStateOf(false) }
|
|
||||||
// Special-dates calendars carry all-day events only, which have no length.
|
|
||||||
val durationCalendars = state.writableCalendars.filterNot { it.id in state.managedCalendarIds }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -102,64 +80,6 @@ internal fun EventFormScreen(
|
|||||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||||
)
|
)
|
||||||
|
|
||||||
// How long a new timed event opens (#54), globally and per calendar —
|
|
||||||
// the per-calendar list folds behind its own header, like the reminders.
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_event_duration),
|
|
||||||
summary = durationLabel(state.defaultEventDurationMinutes),
|
|
||||||
position = if (durationCalendars.isEmpty()) Position.Alone else Position.Top,
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Schedule,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { showDefaultDuration = true },
|
|
||||||
)
|
|
||||||
if (durationCalendars.isNotEmpty()) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_calendar_durations_title),
|
|
||||||
summary = stringResource(R.string.settings_calendar_durations_hint),
|
|
||||||
// Expanded, the header opens the calendar run below it and the
|
|
||||||
// two read as one container (the shared expand pattern).
|
|
||||||
position = if (durationSectionExpanded) Position.Top else Position.Bottom,
|
|
||||||
trailing = {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { durationSectionExpanded = !durationSectionExpanded },
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = durationSectionExpanded,
|
|
||||||
enter = expandEnter(),
|
|
||||||
exit = collapseExit(),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
durationCalendars.forEachIndexed { index, calendar ->
|
|
||||||
val override = state.perCalendarEventDuration[calendar.id]
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
summary = override?.let { durationLabel(it) }
|
|
||||||
?: stringResource(
|
|
||||||
R.string.settings_calendar_duration_inherits,
|
|
||||||
durationLabel(state.defaultEventDurationMinutes),
|
|
||||||
),
|
|
||||||
// The header above is the run's first row, so the
|
|
||||||
// calendars continue it: middles, then its bottom.
|
|
||||||
position = positionOf(index + 1, durationCalendars.size + 1),
|
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
|
||||||
onClick = { durationTarget = calendar.id },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-event colour on calendars that publish no colour set (some
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
// CalDAV); off by default, since it may not survive their next sync.
|
// CalDAV); off by default, since it may not survive their next sync.
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
@@ -180,36 +100,4 @@ internal fun EventFormScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDefaultDuration) {
|
|
||||||
DurationPicker(
|
|
||||||
title = stringResource(R.string.settings_event_duration),
|
|
||||||
description = stringResource(R.string.settings_event_duration_hint),
|
|
||||||
presets = EVENT_DURATION_PRESETS,
|
|
||||||
selected = state.defaultEventDurationMinutes,
|
|
||||||
label = { durationLabel(it) },
|
|
||||||
// No inherit row on the global default, so a pick is never null.
|
|
||||||
onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } },
|
|
||||||
onDismiss = { showDefaultDuration = false },
|
|
||||||
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
durationTarget?.let { calendarId ->
|
|
||||||
DurationPicker(
|
|
||||||
title = stringResource(R.string.settings_event_duration),
|
|
||||||
presets = EVENT_DURATION_PRESETS,
|
|
||||||
selected = state.perCalendarEventDuration[calendarId],
|
|
||||||
label = { durationLabel(it) },
|
|
||||||
inheritLabel = stringResource(
|
|
||||||
R.string.settings_calendar_duration_use_default,
|
|
||||||
durationLabel(state.defaultEventDurationMinutes),
|
|
||||||
),
|
|
||||||
onSelect = { viewModel.setCalendarEventDuration(calendarId, it) },
|
|
||||||
onDismiss = { durationTarget = null },
|
|
||||||
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lengths offered for a new timed event, in minutes — up to a full work day. */
|
|
||||||
private val EVENT_DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240, 480)
|
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
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.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
@@ -16,6 +27,7 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
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.rememberReduceMotion
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,21 +51,39 @@ internal fun MonthViewStylePicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val options = MonthViewStyle.entries
|
val options = MonthViewStyle.entries
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.settings_month_view_style),
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
ViewPreviewFrame(
|
Box(
|
||||||
selected = selected,
|
modifier = Modifier
|
||||||
label = "month-style-preview",
|
.fillMaxWidth()
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
) { shown ->
|
.height(PREVIEW_HEIGHT),
|
||||||
MonthStylePreview(
|
contentAlignment = Alignment.Center,
|
||||||
style = shown,
|
) {
|
||||||
weekStart = weekStart,
|
Crossfade(
|
||||||
height = PREVIEW_HEIGHT,
|
targetState = selected,
|
||||||
)
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = "month-style-preview",
|
||||||
|
) { shown ->
|
||||||
|
// The preview renders the real grid at phone size and scales it
|
||||||
|
// down, so its own corners are square — the frame rounds it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
MonthStylePreview(
|
||||||
|
style = shown,
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The selected style's own blurb lives here, under its preview, rather
|
// The selected style's own blurb lives here, under its preview, rather
|
||||||
// than on every row — the rows stay single-line and dense, and the words
|
// than on every row — the rows stay single-line and dense, and the words
|
||||||
@@ -79,3 +109,4 @@ internal fun MonthViewStylePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val PREVIEW_HEIGHT = 280.dp
|
private val PREVIEW_HEIGHT = 280.dp
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -37,11 +38,10 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -146,7 +146,7 @@ internal fun NotificationsScreen(
|
|||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
summary = durationLabel(state.snoozeMinutes),
|
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||||
position = Position.Bottom,
|
position = Position.Bottom,
|
||||||
onClick = { showSnooze = true },
|
onClick = { showSnooze = true },
|
||||||
)
|
)
|
||||||
@@ -245,13 +245,12 @@ internal fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
DurationPicker(
|
SnoozeDurationPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
presets = SNOOZE_PRESETS,
|
presets = SNOOZE_PRESETS,
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { durationLabel(it) },
|
label = { snoozeDurationLabel(it) },
|
||||||
// No inherit row here, so a pick is never null.
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
|
|
||||||
onDismiss = { showSnooze = false },
|
onDismiss = { showSnooze = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -349,6 +348,15 @@ private fun calendarOverrideSummary(
|
|||||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
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
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||||
|
|||||||
@@ -302,14 +302,13 @@ private fun ReportProblemRow(position: Position) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
|
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// row updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
|
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
val options = remember(supported) { listOf<String?>(null) + supported }
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.common.TimelineScale
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
|
|
||||||
@@ -42,8 +41,6 @@ data class SettingsUiState(
|
|||||||
val showWeekNumbers: Boolean = false,
|
val showWeekNumbers: Boolean = false,
|
||||||
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||||
val todayButtonInToolbar: Boolean = false,
|
val todayButtonInToolbar: Boolean = false,
|
||||||
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
|
||||||
val dragToReschedule: Boolean = true,
|
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
@@ -60,18 +57,12 @@ data class SettingsUiState(
|
|||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
/** How tall an hour is drawn in the week and day timelines (#56). */
|
|
||||||
val timelineScale: TimelineScale = TimelineScale.Regular,
|
|
||||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
val autofocusEventTitle: Boolean = true,
|
val autofocusEventTitle: Boolean = true,
|
||||||
/** How long a new timed event lasts, in minutes (#54); all-day events ignore it. */
|
|
||||||
val defaultEventDurationMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
|
||||||
/** Per-calendar overrides of [defaultEventDurationMinutes]; absent = inherit. */
|
|
||||||
val perCalendarEventDuration: Map<Long, Int> = emptyMap(),
|
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
|
||||||
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
|
||||||
@@ -124,14 +123,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.perCalendarAllDayReminderOverride,
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
writableCalendars,
|
writableCalendars,
|
||||||
prefs.managedCalendarIds,
|
prefs.managedCalendarIds,
|
||||||
// The new-event duration defaults (#54) fold into one flow so they
|
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||||
// fit this group — the outer combine is at its five-arg limit.
|
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||||
combine(
|
|
||||||
prefs.defaultEventDurationMinutes,
|
|
||||||
prefs.perCalendarEventDuration,
|
|
||||||
) { duration, perCalendar -> EventDurations(duration, perCalendar) },
|
|
||||||
) { overrides, allDayOverrides, calendars, managedIds, durations ->
|
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds, durations)
|
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.defaultView,
|
prefs.defaultView,
|
||||||
@@ -145,15 +138,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.showWeekNumbers,
|
prefs.showWeekNumbers,
|
||||||
prefs.agendaShowToday,
|
prefs.agendaShowToday,
|
||||||
prefs.softenCalendarColors,
|
prefs.softenCalendarColors,
|
||||||
combine(
|
prefs.todayButtonInToolbar,
|
||||||
prefs.todayButtonInToolbar,
|
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
||||||
prefs.dragToReschedule,
|
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
||||||
::Pair,
|
|
||||||
),
|
|
||||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
|
||||||
DisplayToggles(
|
|
||||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
@@ -163,7 +150,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaShowToday = toggles.agendaShowToday,
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
dragToReschedule = toggles.dragToReschedule,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -178,9 +164,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.drawerViewOrder,
|
prefs.drawerViewOrder,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.widgetSize,
|
prefs.widgetSize,
|
||||||
prefs.timelineScale,
|
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
||||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
||||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -196,7 +181,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaShowToday = views.agendaShowToday,
|
agendaShowToday = views.agendaShowToday,
|
||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
dragToReschedule = views.dragToReschedule,
|
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
@@ -205,7 +189,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
widgetSize = misc.viewCustomization.widgetSize,
|
widgetSize = misc.viewCustomization.widgetSize,
|
||||||
timelineScale = misc.viewCustomization.timelineScale,
|
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -215,8 +198,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
managedCalendarIds = overrides.managedIds,
|
managedCalendarIds = overrides.managedIds,
|
||||||
defaultEventDurationMinutes = overrides.durations.default,
|
|
||||||
perCalendarEventDuration = overrides.durations.perCalendar,
|
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -279,12 +260,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val allDay: Map<Long, List<Int>>,
|
val allDay: Map<Long, List<Int>>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
val managedIds: Set<Long>,
|
val managedIds: Set<Long>,
|
||||||
val durations: EventDurations,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class EventDurations(
|
|
||||||
val default: Int,
|
|
||||||
val perCalendar: Map<Long, Int>,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ViewSettings(
|
private data class ViewSettings(
|
||||||
@@ -297,7 +272,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DisplayToggles(
|
private data class DisplayToggles(
|
||||||
@@ -306,7 +280,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -322,7 +295,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val widgetSize: WidgetSize,
|
val widgetSize: WidgetSize,
|
||||||
val timelineScale: TimelineScale,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -544,10 +516,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDragToReschedule(enabled: Boolean) {
|
|
||||||
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
* Switch the launcher label (#44). The card highlights at once, then settles
|
||||||
* on what the component state reports; the writes are binder round-trips and
|
* on what the component state reports; the writes are binder round-trips and
|
||||||
@@ -591,15 +559,15 @@ 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, via an atomic
|
||||||
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
* read-modify-write so a concurrent reorder can't clobber it. The
|
||||||
* views may be turned off; below two the pill hides itself (#150).
|
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
||||||
|
* own guard reads an async-echoed snapshot.
|
||||||
*/
|
*/
|
||||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.updateQuickSwitch { config ->
|
prefs.updateQuickSwitch { config ->
|
||||||
config.copy(
|
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||||
enabled = if (enabled) config.enabled + view else config.enabled - view,
|
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,10 +581,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTimelineScale(scale: TimelineScale) {
|
|
||||||
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
@@ -636,15 +600,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDefaultEventDuration(minutes: Int) {
|
|
||||||
viewModelScope.launch { prefs.setDefaultEventDurationMinutes(minutes) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [minutes] null clears the override, so the calendar inherits the default. */
|
|
||||||
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
|
||||||
viewModelScope.launch { prefs.setCalendarEventDuration(calendarId, minutes) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user