Compare commits
15 Commits
renovate/c
...
feat/searc
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ab8fcb5f5 | |||
|
|
4e38866dfd | ||
|
|
44233cff35 | ||
|
|
5764bd889b | ||
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 | ||
|
|
0097a9c534 | ||
|
|
4e51e33356 | ||
|
|
e8657117d6 | ||
|
|
d5c53df6b5 | ||
|
|
2f4e05899a |
@@ -1,4 +1,4 @@
|
|||||||
name: Release — F-Droid repo + Gitea/Codeberg release
|
name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
||||||
|
|
||||||
# A release is cut by merging a release branch into main with a bumped
|
# 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,6 +9,12 @@ name: Release — F-Droid repo + Gitea/Codeberg release
|
|||||||
# 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
|
||||||
@@ -258,9 +264,10 @@ 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 (committed at release-cut time for the official repo; this
|
# fastlane tree. The committed hand-written summary (kept under Play's
|
||||||
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
# 500-char cap) is used as-is; only if it is missing does the script fall
|
||||||
# the commit having happened). The transform below then carries it across.
|
# back to CHANGELOG.md, so the self-hosted repo never depends on the
|
||||||
|
# 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
|
||||||
@@ -477,3 +484,172 @@ 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
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -61,5 +61,16 @@ Thumbs.db
|
|||||||
# 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
|
||||||
|
|||||||
204
CHANGELOG.md
204
CHANGELOG.md
@@ -7,13 +7,201 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
### 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
|
||||||
|
- Settings → Calendars now says what is different about a calendar instead of
|
||||||
|
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||||
|
calendar shared with you read-only — are marked **Read-only** ([#76]).
|
||||||
|
- Calendars your device isn't syncing are marked **Not synced**, moved to the
|
||||||
|
bottom of their account and left without a switch. None of their events are on
|
||||||
|
the device, so the switch they used to have could not have shown you anything
|
||||||
|
— the calendar simply looked broken. They are no longer offered when you pick
|
||||||
|
a calendar for a new or an imported event either: an event saved there would
|
||||||
|
never reach the account. Whether an account syncs a calendar stays that
|
||||||
|
account's own app's decision ([#78]).
|
||||||
|
- The birthday and anniversary calendars Calendula fills from your contacts are
|
||||||
|
marked **Filled from your contacts**, which is why they can't be picked for a
|
||||||
|
new event: anything you put there would be removed again on the next sync.
|
||||||
|
Deleting one is held back while special dates are switched on — Calendula
|
||||||
|
would simply create it again — and the calendar's editor says so; turn the
|
||||||
|
feature off under Settings → Special dates and the delete works as usual
|
||||||
|
([#76]).
|
||||||
|
- The calendar picker in the event form and in the .ics import screen now ends
|
||||||
|
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||||
|
those marks then explain why a calendar isn't offered ([#76]).
|
||||||
|
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
|
||||||
|
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||||
|
the widget used to make from its own measurements. Small is what it looks like
|
||||||
|
today, so nothing changes until you turn it up ([#51]).
|
||||||
|
- A repeating event now shows you its next few dates, not just a description of
|
||||||
|
the rule. Both the preset list and the custom recurrence picker carry a
|
||||||
|
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
|
||||||
|
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
|
||||||
|
can mean something other than it sounds like, and only the dates say so. A
|
||||||
|
rule that can never fire says that instead ([#69]).
|
||||||
|
- The event visibility options now say who they affect. **Public**, **Private**
|
||||||
|
and **Confidential** each carry a line about what other people on a shared
|
||||||
|
calendar see — the part the four words on their own leave out ([#69]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Settings has been reorganised so each setting sits where you would look for
|
||||||
|
it.** One long undifferentiated list is now three labelled groups — Look &
|
||||||
|
behaviour, Data, and App — whose rows open sub-screens and say what they do
|
||||||
|
rather than only naming themselves. Appearance, Views, New event form,
|
||||||
|
Notifications and the new Widgets & tiles are separate screens now, so the
|
||||||
|
settings for a calendar view are no longer mixed in with the ones for the
|
||||||
|
app's colours or for the home-screen widgets ([#69]).
|
||||||
|
- Settings that are hard to picture from their name now show you what they do.
|
||||||
|
The week-start picker rearranges a real month grid as you choose, the
|
||||||
|
past-events setting previews a sample agenda day for Show, Dim and Hide, the
|
||||||
|
font pickers set a specimen line in the face you are choosing, and the Agenda
|
||||||
|
range options carry the dates each one actually covers. Options that follow
|
||||||
|
the system additionally name which way they currently fall ([#69]).
|
||||||
|
- **Backup & restore** is now its own Settings entry instead of living inside
|
||||||
|
the calendar manager, where it was easy to miss — keeping a copy of your
|
||||||
|
calendars is a different question from which calendars you have. The calendar
|
||||||
|
manager keeps a row pointing to it, and nothing about how backup or automatic
|
||||||
|
backup works has changed ([#69]).
|
||||||
- Calendula's source code now lives on **Codeberg**, where its issues already
|
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||||
were. The **Source code** and **License** links in Settings → About point
|
were. The **Source code** and **License** links in Settings → About point
|
||||||
there, so reporting a bug and reading the code no longer land on two different
|
there, so reporting a bug and reading the code no longer land on two different
|
||||||
sites. Nothing about the app itself changes, and the F-Droid repository is
|
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||||
unaffected.
|
unaffected.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A month-grid widget stays a month grid, and draws all seven days again. Since
|
||||||
|
2.16.0 a placed month widget could redraw itself as the agenda widget a little
|
||||||
|
after any change to your events, and could draw only about four day columns
|
||||||
|
with the last one cut off part-way through. Both came from the release build
|
||||||
|
merging the two widgets into a single class, so Android could no longer tell
|
||||||
|
which of them a widget on your home screen was — and the month grid was handed
|
||||||
|
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
|
||||||
|
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||||
|
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||||
|
did the same ([#81]).
|
||||||
|
- The dots standing in for the events that didn't fit a day in the month view
|
||||||
|
now dim with everything else when **Dim completed events** is on. A past day
|
||||||
|
with four or more events kept its last events at full strength while the rest
|
||||||
|
faded ([#79]).
|
||||||
|
- Two accounts that happen to share a name — a Google account and a DAVx5
|
||||||
|
account for the same address, say — are no longer merged into one group.
|
||||||
|
They were listed together in Settings → Calendars and in the drawer's filter,
|
||||||
|
which also meant the group's source icon and its "manage in app" button could
|
||||||
|
send you to the wrong app, "toggle all" spanned both accounts at once, and
|
||||||
|
collapsing one collapsed the other. Where a name really is shared, each group
|
||||||
|
now names the app it comes from ([#77]).
|
||||||
|
- In the month view's **Split** style, the new-event button now starts on the
|
||||||
|
day you have selected. It always started on today, whichever day was selected
|
||||||
|
and listed below the grid ([#87]).
|
||||||
|
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||||
|
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||||
|
day the month, week and agenda views file the same event under ([#82]).
|
||||||
|
- Reminders no longer depend on Android telling Calendula when they are due.
|
||||||
|
Calendula now works out each reminder's time itself and sets its own alarm for
|
||||||
|
it. On some phones — Samsung's among them — the system's calendar storage never
|
||||||
|
sends the signal a calendar app is meant to wake up on, and no amount of
|
||||||
|
battery or notification settings helps: the reminder is simply never announced.
|
||||||
|
None of that is visible from inside an app that waits to be told, which is why
|
||||||
|
it took a second pass to find ([#75]).
|
||||||
|
|
||||||
|
Reminders also survive things that used to lose them quietly. After a restart
|
||||||
|
or an app update Calendula re-arms its alarms, and a reminder whose moment
|
||||||
|
passed while the phone was off still arrives, as long as the event has not
|
||||||
|
ended yet.
|
||||||
|
|
||||||
|
- All-day reminders now arrive at the time you chose in **Settings →
|
||||||
|
Notifications**, on every occurrence. A yearly birthday could drift an hour
|
||||||
|
either way depending on daylight saving, and all-day reminders on calendars
|
||||||
|
from an account fired in the middle of the night instead of in the morning
|
||||||
|
([#75]).
|
||||||
|
|
||||||
|
- Reminders now arrive for every calendar you have switched on. A calendar that
|
||||||
|
was hidden at system level — switched off in another calendar app, or never
|
||||||
|
switched on after being added — still showed its events and listed their
|
||||||
|
reminders in Calendula, but never notified: Android only schedules reminder
|
||||||
|
alarms for calendars marked visible, and Calendula kept its own separate
|
||||||
|
on/off list that had no say in it. There is now one switch: **Settings →
|
||||||
|
Calendars** turns a calendar on or off for the whole device, so what you see
|
||||||
|
and what reminds you can no longer disagree ([#75]).
|
||||||
|
|
||||||
|
Calendars you had switched off in Calendula are switched off here too on first
|
||||||
|
launch. Calendars that were already off — hidden in another calendar app, or
|
||||||
|
never switched on after being added — stay off, and Calendula says so once
|
||||||
|
rather than quietly switching them on for every app on your device; you can
|
||||||
|
turn any of them back on in Settings → Calendars.
|
||||||
|
|
||||||
|
If you gave Calendula read-only access to your calendars, the switch still
|
||||||
|
works: your choice is kept in the app until it can be written.
|
||||||
|
|
||||||
|
The drawer's filter is unchanged and still app-only: hiding a calendar there
|
||||||
|
tidies your view without silencing its reminders.
|
||||||
|
|
||||||
## [2.16.0] — 2026-07-24
|
## [2.16.0] — 2026-07-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1119,3 +1307,19 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
||||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||||
|
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||||
|
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||||
|
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||||
|
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
|
||||||
|
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||||
|
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||||
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
|
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
|
||||||
|
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||||
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
|
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||||
|
[#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
|
||||||
|
|||||||
10
Gemfile
Normal file
10
Gemfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
# fastlane is used ONLY to upload the release bundle to Google Play
|
||||||
|
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
|
||||||
|
# it never runs on a PR — only in release.yaml's `play` job.
|
||||||
|
#
|
||||||
|
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
||||||
|
# committed on purpose: this resolves an uploader's transitive deps, not the
|
||||||
|
# app's, and none of it affects the reproducible release build.
|
||||||
|
gem "fastlane", "2.237.0"
|
||||||
14
README.md
14
README.md
@@ -18,7 +18,7 @@ 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://codeberg.org/jlmakiola/calendula/releases"><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://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>
|
||||||
@@ -125,13 +125,11 @@ signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — downlo
|
|||||||
and install it directly.
|
and install it directly.
|
||||||
|
|
||||||
For automatic updates from that channel, use
|
For automatic updates from that channel, use
|
||||||
**[Obtainium](https://github.com/ImranR98/Obtainium)**: *Add App* → paste
|
**[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`
|
||||||
https://codeberg.org/jlmakiola/calendula
|
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
||||||
```
|
one appears.
|
||||||
|
|
||||||
→ *Add*. Obtainium tracks the releases and prompts you when a new one appears.
|
|
||||||
|
|
||||||
### Google Play (coming soon)
|
### Google Play (coming soon)
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 21600
|
versionCode = 21800
|
||||||
versionName = "2.16.0"
|
versionName = "2.18.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -38,3 +38,15 @@
|
|||||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||||
|
|
||||||
|
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
||||||
|
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
||||||
|
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
||||||
|
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
||||||
|
# same overrides, no distinguishing members — were horizontally merged into one
|
||||||
|
# class, so both receivers registered under the *same* provider name and
|
||||||
|
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
||||||
|
# placed month widget as the agenda one on the next data change (#89). Keeping
|
||||||
|
# the real names also survives app updates, which would otherwise renumber the
|
||||||
|
# obfuscated name and orphan the stored mapping.
|
||||||
|
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A reboot clears every pending alarm, including the one holding the next
|
||||||
|
reminder. Now that the app schedules that alarm itself (#75) rather than
|
||||||
|
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
||||||
|
otherwise reminders simply stop after a restart.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -270,17 +278,22 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
Instances + Reminders instead of waiting for the provider's
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||||
|
retarget or never send. This receiver takes our scan alarm plus
|
||||||
|
every outside event that invalidates it — boot and package-replace
|
||||||
|
wipe pending alarms, and a clock or timezone change moves every
|
||||||
|
reminder relative to the one that is armed.
|
||||||
|
Exported: the system broadcasts arrive from outside the app. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.EventReminderReceiver"
|
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
<data
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
android:host="com.android.calendar"
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
android:scheme="content" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import android.app.Application
|
|||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -39,6 +42,37 @@ class CalendulaApp : Application() {
|
|||||||
)
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
|
reconcileCalendarVisibility()
|
||||||
|
startReminderDelivery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring reminder delivery up with the process (#75): a scan re-arms whatever
|
||||||
|
* the system dropped and posts what a missed alarm still owes, then the
|
||||||
|
* provider watch keeps edits re-planned. The daily worker is the backstop.
|
||||||
|
*/
|
||||||
|
private fun startReminderDelivery() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(
|
||||||
|
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||||
|
)
|
||||||
|
val scanner = deps.reminderScanner()
|
||||||
|
scanner.startWatchingProvider()
|
||||||
|
scanner.scanInBackground()
|
||||||
|
ReminderMaintenanceScheduler.apply(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush any calendar switch-off not yet written to `Calendars.VISIBLE`,
|
||||||
|
* including the set inherited from the retired app-local model (#75). A
|
||||||
|
* no-op in the steady state; `RootScreen` re-runs it after a later grant.
|
||||||
|
*/
|
||||||
|
private fun reconcileCalendarVisibility() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(
|
||||||
|
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||||
|
)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
deps.calendarVisibilityReconciler().run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,10 +95,8 @@ class CalendulaApp : Application() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||||
* settings on every launch — like [reconcileAutoBackup], this cancels
|
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
|
||||||
* orphaned work after the feature is turned off and re-schedules after a
|
* ON_RESUME trigger, so no immediate run is needed here.
|
||||||
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
|
||||||
* refresh, so no immediate run is needed here.
|
|
||||||
*/
|
*/
|
||||||
private fun reconcileSpecialDates() {
|
private fun reconcileSpecialDates() {
|
||||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ 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
|
||||||
@@ -12,6 +14,7 @@ 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
|
||||||
@@ -29,11 +32,14 @@ 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
|
||||||
@@ -50,9 +56,17 @@ 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.
|
||||||
@@ -69,7 +83,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 requestedInsertForm by mutableStateOf<EventForm?>(null)
|
private var requestedInsert by mutableStateOf<InsertRequest?>(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
|
||||||
@@ -96,11 +110,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
|
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()
|
||||||
requestedInsertForm = intent.insertFormOrNull()
|
requestedInsert = intent.insertRequestOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
@@ -113,6 +129,14 @@ 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.
|
||||||
@@ -125,6 +149,13 @@ 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(
|
||||||
@@ -141,6 +172,7 @@ 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(
|
||||||
@@ -151,8 +183,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsert?.form,
|
||||||
onInsertConsumed = { requestedInsertForm = null },
|
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||||
|
onInsertConsumed = { requestedInsert = null },
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
@@ -181,6 +214,19 @@ 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.
|
||||||
@@ -192,7 +238,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.insertFormOrNull()?.let { requestedInsertForm = it }
|
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,9 +265,10 @@ 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].
|
* [buildInsertEventForm]. An intent that names no end time leaves the length
|
||||||
|
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertRequestOrNull(): InsertRequest? {
|
||||||
// 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.
|
||||||
@@ -229,9 +276,11 @@ 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
|
||||||
return buildInsertEventForm(
|
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
|
||||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
val form = buildInsertEventForm(
|
||||||
|
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),
|
||||||
@@ -240,6 +289,10 @@ 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. */
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import java.time.Instant
|
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
@@ -73,19 +72,13 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
* the reminder actually fires on.
|
||||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
|
||||||
* after DTSTART) folds to day 0.
|
|
||||||
*/
|
*/
|
||||||
internal fun fromProviderAllDayMinutes(
|
internal fun fromProviderAllDayMinutes(
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Int {
|
timeOfDayMinutes: Int,
|
||||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
|
||||||
.atZone(zone).toLocalDate()
|
|
||||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ import javax.inject.Singleton
|
|||||||
interface CalendarDataSource {
|
interface CalendarDataSource {
|
||||||
fun calendars(): List<CalendarSource>
|
fun calendars(): List<CalendarSource>
|
||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
/**
|
||||||
|
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||||
|
* decode their stored offsets back to whole-day lead times (see
|
||||||
|
* [fromProviderAllDayMinutes]).
|
||||||
|
*/
|
||||||
|
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master/one-off events whose title, description or location contains
|
* Master/one-off events whose title, description or location contains
|
||||||
@@ -110,6 +115,28 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
|
||||||
|
* app's one visibility model (#75), which also gates its reminders. One of
|
||||||
|
* the three columns the platform documents as app-writable, and device-local.
|
||||||
|
*/
|
||||||
|
fun setCalendarVisible(id: Long, visible: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether one calendar is switched on at system level, without reading every
|
||||||
|
* row — for the reminder gate, which sees only a calendar id. Null when the
|
||||||
|
* answer can't be had: no row, or no read permission.
|
||||||
|
*/
|
||||||
|
fun isCalendarVisible(id: Long): Boolean?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
|
||||||
|
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
|
||||||
|
* keep their calendar switches app-side instead — see
|
||||||
|
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
|
||||||
|
*/
|
||||||
|
fun canWriteCalendars(): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||||
@@ -373,6 +400,37 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI, one
|
||||||
|
* calendar per call. Both parts are load-bearing:
|
||||||
|
* `CalendarProvider2.updateInTransaction` skips the dirty marking and the
|
||||||
|
* `checkNextAlarm()` reschedule unless the selection is `_id=…`, and the
|
||||||
|
* plain URI is what makes the write apply to synced calendars too.
|
||||||
|
*/
|
||||||
|
override fun setCalendarVisible(id: Long, visible: Boolean) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
|
||||||
|
}
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||||
|
values, null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isCalendarVisible(id: Long): Boolean? {
|
||||||
|
if (!hasCalendarPermission()) return null
|
||||||
|
return resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||||
|
arrayOf(CalendarContract.Calendars.VISIBLE),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canWriteCalendars(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -558,9 +616,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// A recurring master's DTSTART is the series start; show its
|
// A recurring master's DTSTART is the series start; show its
|
||||||
// nearest occurrence instead so the date is the one the user
|
// nearest occurrence instead so the date is the one the user
|
||||||
// actually cares about (and sorting reflects it).
|
// actually cares about (and sorting reflects it).
|
||||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
out += if (base.isRecurring) {
|
||||||
!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(),
|
||||||
@@ -610,7 +666,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
@@ -619,7 +675,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
else CursorColumnReader(c).toEventDetailCore(
|
||||||
|
attendees, reminders, allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,5 +31,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
isManaged = isLocal &&
|
isManaged = isLocal &&
|
||||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||||
|
// NULL is treated as syncing — the harmless default.
|
||||||
|
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
||||||
|
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ interface CalendarRepository {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
suspend fun deleteCalendar(id: Long)
|
suspend fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which also gates
|
||||||
|
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
|
||||||
|
* at a time, in order; a failure part-way leaves the earlier writes standing.
|
||||||
|
*
|
||||||
|
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
||||||
|
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
|
||||||
|
*/
|
||||||
|
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every event of the writable local calendars, ready to serialise into a
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -47,52 +53,120 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped on every provider notification, so one tick's calendar read can be
|
||||||
|
* shared by everything that needs it (see [calendarsSnapshot]).
|
||||||
|
*/
|
||||||
|
private val generation = AtomicLong(0L)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
|
dataSource.registerChangeListener {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
ticks.tryEmit(Unit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun calendars(): Flow<List<CalendarSource>> =
|
/**
|
||||||
ticks
|
* Re-query signal for everything filtered by visibility: the provider's own
|
||||||
.onStart { emit(Unit) }
|
* notifications, plus every change to the pending switch-off set.
|
||||||
.reQuery { dataSource.calendars() }
|
*/
|
||||||
.flowOn(io)
|
private fun visibilityTicks(): Flow<Unit> = merge(
|
||||||
|
ticks.onStart { emit(Unit) },
|
||||||
|
// drop(1): the current value is already covered by the tick above.
|
||||||
|
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
||||||
|
)
|
||||||
|
|
||||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
// A switch-off not yet written to the provider is folded into the flag
|
||||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
// itself, so every consumer reads one visibility (#75). The reconciler goes
|
||||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
// to the data source directly — it needs the provider's own answer.
|
||||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
override fun calendars(): Flow<List<CalendarSource>> =
|
||||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
visibilityTicks().reQuery {
|
||||||
// list and re-enable hidden/disabled calendars.
|
val calendars = calendarsSnapshot()
|
||||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
|
||||||
combine(
|
if (pendingDisabled.isEmpty()) calendars
|
||||||
ticks
|
else calendars.map {
|
||||||
.onStart { emit(Unit) }
|
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
|
||||||
.reQuery {
|
}
|
||||||
dataSource.instances(
|
|
||||||
beginMillis = range.start.toEpochMillis(),
|
|
||||||
endMillis = range.endInclusive.toEpochMillis(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
prefs.hiddenCalendarIds,
|
|
||||||
prefs.disabledCalendarIds,
|
|
||||||
) { instances, hidden, disabled ->
|
|
||||||
val excluded = hidden + disabled
|
|
||||||
if (excluded.isEmpty()) instances
|
|
||||||
else instances.filterNot { it.calendarId in excluded }
|
|
||||||
}
|
}
|
||||||
// hidden and disabled both derive from one DataStore, so toggling
|
|
||||||
// either makes both re-emit and combine briefly surfaces the same
|
|
||||||
// list twice — collapse the duplicate so views don't re-render for it.
|
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
|
// Instances are filtered by the system's VISIBLE flag ∪ the switch-offs
|
||||||
|
// still waiting to be written to it ∪ the app-side hidden set from the
|
||||||
|
// filter sheet. [calendars] stays unfiltered so those screens can list and
|
||||||
|
// re-enable invisible calendars.
|
||||||
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
|
combine(
|
||||||
|
visibilityTicks().reQuery {
|
||||||
|
// All three reads in one pass, so a list of instances is never
|
||||||
|
// filtered against a visibility snapshot from another tick.
|
||||||
|
QueriedInstances(
|
||||||
|
instances = dataSource.instances(
|
||||||
|
beginMillis = range.start.toEpochMillis(),
|
||||||
|
endMillis = range.endInclusive.toEpochMillis(),
|
||||||
|
),
|
||||||
|
switchedOffCalendarIds = invisibleCalendarIds() +
|
||||||
|
prefs.pendingDisabledCalendarIds.first(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prefs.hiddenCalendarIds,
|
||||||
|
) { queried, hidden ->
|
||||||
|
val excluded = hidden + queried.switchedOffCalendarIds
|
||||||
|
if (excluded.isEmpty()) queried.instances
|
||||||
|
else queried.instances.filterNot { it.calendarId in excluded }
|
||||||
|
}
|
||||||
|
// Any DataStore edit re-emits the hidden set even when unchanged;
|
||||||
|
// collapse those so views don't re-render for them.
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
|
/** One instances query plus the visibility it must be filtered against. */
|
||||||
|
private data class QueriedInstances(
|
||||||
|
val instances: List<EventInstance>,
|
||||||
|
val switchedOffCalendarIds: Set<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Calendars switched off at system level — hidden, and never reminded about. */
|
||||||
|
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
||||||
|
.filterNot { it.isVisibleInSystem }
|
||||||
|
.mapTo(mutableSetOf()) { it.id }
|
||||||
|
|
||||||
|
private val calendarsLock = Mutex()
|
||||||
|
private var cachedGeneration = -1L
|
||||||
|
private var cachedPending: Set<Long>? = null
|
||||||
|
private var cachedCalendars: List<CalendarSource> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar list for the current tick, queried once and shared, so every
|
||||||
|
* open view doesn't pay for its own `Calendars` query and all of them see
|
||||||
|
* one snapshot.
|
||||||
|
*
|
||||||
|
* An empty result is never cached — that is what a read without the calendar
|
||||||
|
* permission returns, and the grant itself doesn't notify the provider. The
|
||||||
|
* pending switch-off set keys the cache alongside the tick, because an id
|
||||||
|
* leaves it before the invalidating observer is dispatched.
|
||||||
|
*/
|
||||||
|
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
|
||||||
|
val current = generation.get()
|
||||||
|
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||||
|
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
|
||||||
|
cachedCalendars = dataSource.calendars()
|
||||||
|
cachedGeneration = current
|
||||||
|
cachedPending = pending
|
||||||
|
}
|
||||||
|
cachedCalendars
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||||
|
?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
val excluded = prefs.hiddenCalendarIds.first() +
|
||||||
|
prefs.pendingDisabledCalendarIds.first() +
|
||||||
|
invisibleCalendarIds()
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
@@ -118,6 +192,22 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
|
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||||
|
withContext(io) {
|
||||||
|
if (dataSource.canWriteCalendars()) {
|
||||||
|
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||||
|
// Nothing of ours is left waiting for the provider once the
|
||||||
|
// write lands (and switching one back on retires its entry).
|
||||||
|
prefs.removePendingDisabledCalendarIds(ids)
|
||||||
|
} else if (visible) {
|
||||||
|
prefs.removePendingDisabledCalendarIds(ids)
|
||||||
|
} else {
|
||||||
|
// Read-only permission: the switch still works, app-side, and
|
||||||
|
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
|
||||||
|
prefs.addPendingDisabledCalendarIds(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
|
||||||
|
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
|
||||||
|
* visibility model, and the standing drain for switch-offs made without
|
||||||
|
* `WRITE_CALENDAR`.
|
||||||
|
*
|
||||||
|
* Runs on every launch and on every calendar-permission grant; a no-op once the
|
||||||
|
* pending set is empty and the notice is settled. Only ever hides (see
|
||||||
|
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
|
||||||
|
* system-hidden calendar arms the one-time explanatory notice.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CalendarVisibilityReconciler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val dataSource: CalendarDataSource,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun run() = withContext(io) {
|
||||||
|
// The DataStore reads sit inside the guard too: this runs in a bare
|
||||||
|
// application-scope coroutine, so an IOException from a damaged
|
||||||
|
// preferences file would take the process down on every launch.
|
||||||
|
try {
|
||||||
|
// Settled ahead of the permission gate, so an update installed
|
||||||
|
// before the first grant can't later look like an upgrade.
|
||||||
|
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||||
|
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||||
|
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||||
|
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||||
|
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||||
|
val calendars = dataSource.calendars()
|
||||||
|
// Empty means "couldn't read" (null cursor), not "no calendars".
|
||||||
|
// Both decisions below are one-way, so leave them to the next run.
|
||||||
|
if (calendars.isEmpty()) return@withContext
|
||||||
|
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||||
|
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val plan = calendarVisibilityPlan(calendars, pending)
|
||||||
|
prefs.removePendingDisabledCalendarIds(plan.settled)
|
||||||
|
// One calendar per write: the provider skips its reminder-alarm
|
||||||
|
// reschedule for anything but a single-id update (see
|
||||||
|
// [CalendarDataSource.setCalendarVisible]).
|
||||||
|
for (id in plan.hide) {
|
||||||
|
dataSource.setCalendarVisible(id, false)
|
||||||
|
prefs.removePendingDisabledCalendarIds(setOf(id))
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle the one-time notice: [pending] arms it, false retires it unshown.
|
||||||
|
* Answered once and stored either way, so it can't resurface later.
|
||||||
|
*/
|
||||||
|
private suspend fun settleNoticeOnce(pending: Boolean) {
|
||||||
|
if (prefs.visibilityNoticePending.first() != null) return
|
||||||
|
prefs.setVisibilityNoticePending(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this install has ever run an earlier version. The notice explains
|
||||||
|
* a behaviour change, so a first install has nothing to announce.
|
||||||
|
*/
|
||||||
|
private fun isUpgradeInstall(): Boolean = try {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
info.lastUpdateTime > info.firstInstallTime
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPermission(permission: String): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "CalendarVisibility"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ private const val TAG = "EventDetailMapper"
|
|||||||
internal fun ColumnReader.toEventDetailCore(
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
@@ -89,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val displayReminders = if (isAllDay) {
|
val displayReminders = if (isAllDay) {
|
||||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
reminders.map {
|
||||||
|
it.copy(
|
||||||
|
minutes = fromProviderAllDayMinutes(
|
||||||
|
it.minutes, startDate, zone, allDayReminderTimeMinutes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
reminders
|
reminders
|
||||||
}
|
}
|
||||||
@@ -118,6 +125,7 @@ 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal object CalendarProjection {
|
|||||||
// uses to recognise its own managed calendars, independent of any
|
// uses to recognise its own managed calendars, independent of any
|
||||||
// stored preference id (which a backup restore / data wipe can lose).
|
// stored preference id (which a backup restore / data wipe can lose).
|
||||||
MANAGED_MARKER_COLUMN,
|
MANAGED_MARKER_COLUMN,
|
||||||
|
CalendarContract.Calendars.SYNC_EVENTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
@@ -36,6 +37,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_ACCESS_LEVEL = 6
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
const val IDX_DESCRIPTION = 7
|
const val IDX_DESCRIPTION = 7
|
||||||
const val IDX_MANAGED_MARKER = 8
|
const val IDX_MANAGED_MARKER = 8
|
||||||
|
const val IDX_SYNC_EVENTS = 9
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -87,6 +89,9 @@ 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
|
||||||
@@ -108,6 +113,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,5 +40,7 @@ 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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderInstanceSource(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: ProviderReminderInstanceSource,
|
||||||
): ReminderAlertStore
|
): ReminderInstanceSource
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.prefs
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-side preference for "calendars the user has hidden in this app",
|
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
||||||
* separate from the system's per-calendar VISIBLE flag.
|
* sheet — a purely in-app declutter that deliberately does *not* suppress
|
||||||
|
* reminders. Switching a calendar off entirely is the system's
|
||||||
|
* `Calendars.VISIBLE` flag, written straight to the provider (#75);
|
||||||
|
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
|
||||||
|
* allowed to write yet.
|
||||||
*
|
*
|
||||||
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||||
* silently dropped (defensive — see CalendarPrefsTest).
|
* silently dropped (defensive — see CalendarPrefsTest).
|
||||||
@@ -22,46 +29,56 @@ class CalendarPrefs @Inject constructor(
|
|||||||
private val store: DataStore<Preferences>,
|
private val store: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
|
||||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
// unrelated write would otherwise re-emit an identical set.
|
||||||
.split(',')
|
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
||||||
.mapNotNull { it.trim().toLongOrNull() }
|
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
||||||
.toSet()
|
.distinctUntilChanged()
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||||
|
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch-offs the provider does not know about yet, because writing
|
||||||
|
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
||||||
|
* retired app-local model's set, from the same key.
|
||||||
|
*
|
||||||
|
* Honoured as a display and reminder filter while non-empty, but not a
|
||||||
|
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
|
||||||
|
* entry as soon as the app may write, and nothing adds to it while it may.
|
||||||
|
*/
|
||||||
|
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
|
||||||
|
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||||
|
editPendingDisabled { it + ids }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop [ids] from the pending set, one at a time as the reconciler flushes
|
||||||
|
* them, so a run that fails part-way never re-applies what already landed.
|
||||||
|
*/
|
||||||
|
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||||
|
editPendingDisabled { it - ids.toSet() }
|
||||||
|
|
||||||
|
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
if (ids.isEmpty()) {
|
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||||
prefs.remove(HIDDEN_IDS_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-side preference for "calendars the user has disabled in this app" — a
|
* Whether the one-time "visibility follows this device" notice is still
|
||||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||||
* every surface (drawer filter, event-form picker, import picker) and its
|
* permission), false once it has been shown or was never needed.
|
||||||
* events never appear; it stays listed only in Settings → Calendars so it can
|
|
||||||
* be re-enabled. Stored exactly like the hidden set; never touches the
|
|
||||||
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
|
||||||
*/
|
*/
|
||||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
prefs[VISIBILITY_NOTICE_KEY]
|
||||||
.split(',')
|
|
||||||
.mapNotNull { it.trim().toLongOrNull() }
|
|
||||||
.toSet()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||||
if (ids.isEmpty()) {
|
|
||||||
prefs.remove(DISABLED_IDS_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +96,16 @@ class CalendarPrefs @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||||
|
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
|
||||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String?.parseIds(): Set<Long> = orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
|
||||||
|
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far reminder delivery has got — the one number replacing the provider's
|
||||||
|
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
|
||||||
|
* watermark and up to now, then moves it to now, so a scan running twice cannot
|
||||||
|
* post twice while a late one still catches up.
|
||||||
|
*
|
||||||
|
* Unset means "never scanned", not zero: the first scan after an install would
|
||||||
|
* otherwise treat every reminder since the epoch as overdue.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderStatePrefs @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** The watermark, or `null` before the first scan has ever run. */
|
||||||
|
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||||
|
|
||||||
|
suspend fun setLastScanMillis(millis: Long) {
|
||||||
|
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,12 @@ 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 java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -269,6 +273,19 @@ 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 "+"
|
||||||
@@ -306,6 +323,23 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The size step the agenda widget draws its text at (#51). Defaults to
|
||||||
|
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
|
||||||
|
* widget is unchanged until its owner turns the size up.
|
||||||
|
*
|
||||||
|
* This replaced deriving a size tier from the widget's measured size, which
|
||||||
|
* the launcher does not report reliably. The month widget takes no size
|
||||||
|
* setting — it divides the width it is given by seven (#103).
|
||||||
|
*/
|
||||||
|
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
|
||||||
|
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setWidgetSize(size: WidgetSize) {
|
||||||
|
store.edit { it[WIDGET_SIZE_KEY] = size.name }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||||
* the session range switcher (v2.11). Default ON.
|
* the session range switcher (v2.11). Default ON.
|
||||||
@@ -439,6 +473,46 @@ 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
|
||||||
@@ -781,6 +855,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
|
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||||
internal val AGENDA_SHOW_TODAY_KEY =
|
internal val AGENDA_SHOW_TODAY_KEY =
|
||||||
booleanPreferencesKey("agenda_show_today")
|
booleanPreferencesKey("agenda_show_today")
|
||||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
@@ -789,12 +864,22 @@ 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 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 ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
@@ -886,6 +971,17 @@ 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"
|
||||||
|
|
||||||
@@ -933,6 +1029,27 @@ 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
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
|
||||||
* reminder must be suppressed (mirroring the event filtering in
|
|
||||||
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
|
||||||
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
|
||||||
* never treated as disabled. This is the one predicate the disabled-calendar
|
|
||||||
* gate is built from: [postableAlerts] here and the choke point in
|
|
||||||
* [ReminderNotifier.post] both use it.
|
|
||||||
*/
|
|
||||||
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
|
||||||
calendarId != 0L && calendarId in disabledCalendarIds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The due alerts that should actually surface as notifications: everything
|
|
||||||
* except alerts whose calendar the user has disabled in-app. The caller still
|
|
||||||
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
|
||||||
* provider.
|
|
||||||
*/
|
|
||||||
internal fun postableAlerts(
|
|
||||||
due: List<ReminderAlert>,
|
|
||||||
disabledCalendarIds: Set<Long>,
|
|
||||||
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
|
||||||
* visible notifications (the Etar model — the provider broadcasts
|
|
||||||
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
|
||||||
*
|
|
||||||
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
|
||||||
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
|
||||||
* them, and mark them fired. Posting happens before marking — a crash in
|
|
||||||
* between re-posts silently (same tag) rather than losing the reminder.
|
|
||||||
*/
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class EventReminderReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
@Inject lateinit var alertStore: ReminderAlertStore
|
|
||||||
@Inject lateinit var notifier: ReminderNotifier
|
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
|
||||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
|
||||||
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
|
||||||
val readGranted = ContextCompat.checkSelfPermission(
|
|
||||||
context, Manifest.permission.READ_CALENDAR,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!readGranted || !notifier.canPost()) return
|
|
||||||
|
|
||||||
val pendingResult = goAsync()
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
if (settingsPrefs.remindersEnabled.first()) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val due = alertStore.dueAlerts(now)
|
|
||||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
|
||||||
val postable = postableAlerts(due, disabled)
|
|
||||||
// Suppress reminders for disabled calendars, but still mark
|
|
||||||
// every due alert fired so the provider stops re-broadcasting
|
|
||||||
// the suppressed ones. Stash those suppressed alerts so
|
|
||||||
// re-enabling their calendar can recover them (they would
|
|
||||||
// otherwise stay STATE_FIRED forever with no re-scan).
|
|
||||||
postable.forEach { notifier.post(it) }
|
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
|
||||||
suppressedStore.stash(due - postable.toSet(), now)
|
|
||||||
suppressedStore.purgeExpired(now)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -18,12 +19,14 @@ import javax.inject.Inject
|
|||||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
* alarm), so the receiver is not exported.
|
* alarm), so the receiver is not exported.
|
||||||
*
|
*
|
||||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||||
* already fired, so nothing re-posts it.
|
* past this reminder, so nothing re-posts it.
|
||||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
* it after the user's snooze delay.
|
* it after the user's snooze delay.
|
||||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||||
* or dismiss it again.
|
* or dismiss it again — unless the calendar was switched off during the
|
||||||
|
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||||
|
* trigger, outside the ordinary scan.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ReminderActionReceiver : BroadcastReceiver() {
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
@@ -73,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||||
|
|
||||||
private const val EXTRA_ALERT_ID = "alert_id"
|
/**
|
||||||
|
* Not handled here — the notification body opens the detail screen
|
||||||
|
* directly. It only claims a slot in [requestCode] so that intent stays
|
||||||
|
* distinct from the three this receiver does handle.
|
||||||
|
*/
|
||||||
|
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||||
|
|
||||||
|
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||||
private const val EXTRA_EVENT_ID = "event_id"
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
private const val EXTRA_BEGIN = "begin"
|
private const val EXTRA_BEGIN = "begin"
|
||||||
@@ -82,11 +92,16 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_LOCATION = "location"
|
private const val EXTRA_LOCATION = "location"
|
||||||
private const val EXTRA_ALL_DAY = "all_day"
|
private const val EXTRA_ALL_DAY = "all_day"
|
||||||
|
|
||||||
/** An explicit intent to this receiver carrying [alert] as extras. */
|
/**
|
||||||
|
* An explicit intent to this receiver carrying [alert] as extras. The
|
||||||
|
* data URI duplicates no information but is what keeps two reminders'
|
||||||
|
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||||
|
*/
|
||||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
this.action = action
|
this.action = action
|
||||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
data = "calendula://reminder/${alert.key}".toUri()
|
||||||
|
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
@@ -97,23 +112,25 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stable request code per (alert, action) so the three PendingIntents
|
* A stable request code per (alert, action), so one notification's
|
||||||
* of one notification stay distinct and don't clobber each other.
|
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||||
|
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||||
*/
|
*/
|
||||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
val actionOffset = when (action) {
|
val actionOffset = when (action) {
|
||||||
ACTION_SNOOZE -> 1
|
ACTION_SNOOZE -> 1
|
||||||
ACTION_DISMISS -> 2
|
ACTION_DISMISS -> 2
|
||||||
ACTION_SHOW -> 3
|
ACTION_SHOW -> 3
|
||||||
|
ACTION_OPEN -> 4
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
return alert.alertId.toInt() * 8 + actionOffset
|
return (alert.key.toInt() shl 3) + actionOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
||||||
|
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
||||||
|
* calendar app), user-revocable on 31–32.
|
||||||
|
*/
|
||||||
|
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
||||||
|
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
||||||
|
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
|
||||||
|
* withholds the capability (API 31–32 with the permission revoked).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderAlarmScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun scheduleScan(triggerAtMillis: Long) {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
if (alarmManager.canScheduleExactCompat()) {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||||
|
fun cancelScan() {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
SCAN_REQUEST_CODE,
|
||||||
|
Intent(context, ReminderScheduleReceiver::class.java)
|
||||||
|
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
||||||
|
flags or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
||||||
|
const val SCAN_REQUEST_CODE = 0x5CA1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One reminder as the notification layer needs it: what to show, and the stable
|
||||||
|
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
|
||||||
|
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
|
||||||
|
* `CalendarAlerts` row to take an id from (#75).
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val key: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||||
|
key = key,
|
||||||
|
eventId = instance.eventId,
|
||||||
|
calendarId = instance.calendarId,
|
||||||
|
beginMillis = instance.beginMillis,
|
||||||
|
endMillis = instance.endMillis,
|
||||||
|
title = instance.title,
|
||||||
|
location = instance.location,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
)
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import android.util.Log
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
|
||||||
* Stays in the data layer: alerts feed the notification path only and never
|
|
||||||
* reach a screen, so there is no domain model for them.
|
|
||||||
*/
|
|
||||||
data class ReminderAlert(
|
|
||||||
val alertId: Long,
|
|
||||||
val eventId: Long,
|
|
||||||
val calendarId: Long,
|
|
||||||
val beginMillis: Long,
|
|
||||||
val endMillis: Long,
|
|
||||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
|
||||||
val title: String,
|
|
||||||
val location: String?,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
|
||||||
* without a ContentResolver. The provider creates these rows itself — only
|
|
||||||
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
|
||||||
* email reminders never show up here.
|
|
||||||
*/
|
|
||||||
interface ReminderAlertStore {
|
|
||||||
|
|
||||||
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
|
||||||
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
|
||||||
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
|
||||||
* which the user may have declined — then re-broadcasts silently replace
|
|
||||||
* the already-posted notifications instead (same tag, alert-once).
|
|
||||||
*/
|
|
||||||
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class AndroidReminderAlertStore @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
) : ReminderAlertStore {
|
|
||||||
|
|
||||||
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
|
||||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
|
||||||
PROJECTION,
|
|
||||||
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
|
||||||
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
|
||||||
arrayOf(
|
|
||||||
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
|
||||||
nowMillis.toString(),
|
|
||||||
),
|
|
||||||
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
|
||||||
)?.use { c ->
|
|
||||||
buildList {
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
add(
|
|
||||||
ReminderAlert(
|
|
||||||
alertId = c.getLong(0),
|
|
||||||
eventId = c.getLong(1),
|
|
||||||
calendarId = c.getLong(2),
|
|
||||||
beginMillis = c.getLong(3),
|
|
||||||
endMillis = c.getLong(4),
|
|
||||||
title = c.getString(5).orEmpty(),
|
|
||||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
|
||||||
isAllDay = c.getInt(7) == 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
|
||||||
if (alertIds.isEmpty()) return
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
|
||||||
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
|
||||||
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.contentResolver.update(
|
|
||||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
|
||||||
values,
|
|
||||||
CalendarContract.CalendarAlerts._ID +
|
|
||||||
" IN (" + alertIds.joinToString(",") + ")",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "ReminderAlertStore"
|
|
||||||
val PROJECTION = arrayOf(
|
|
||||||
CalendarContract.CalendarAlerts._ID,
|
|
||||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
|
||||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
|
||||||
CalendarContract.CalendarAlerts.BEGIN,
|
|
||||||
CalendarContract.CalendarAlerts.END,
|
|
||||||
CalendarContract.CalendarAlerts.TITLE,
|
|
||||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
|
||||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read side of in-house reminder delivery: occurrences and their reminder
|
||||||
|
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
|
||||||
|
* which cannot be assumed to be written (#75).
|
||||||
|
*
|
||||||
|
* An interface so [ReminderScanner] can be exercised on the JVM.
|
||||||
|
*/
|
||||||
|
interface ReminderInstanceSource {
|
||||||
|
|
||||||
|
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
||||||
|
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
||||||
|
|
||||||
|
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
||||||
|
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
||||||
|
* window can be stretched to cover it and a long-lead reminder is planned
|
||||||
|
* before it comes due rather than firing late.
|
||||||
|
*/
|
||||||
|
fun longestReminderMinutes(): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ProviderReminderInstanceSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderInstanceSource {
|
||||||
|
|
||||||
|
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
|
ContentUris.appendId(this, fromMillis)
|
||||||
|
ContentUris.appendId(this, toMillis)
|
||||||
|
}.build()
|
||||||
|
// `visible` is the flag the app's one visibility model writes (#75).
|
||||||
|
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||||
|
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||||
|
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||||
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||||
|
return context.contentResolver.query(
|
||||||
|
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderEventInstance(
|
||||||
|
eventId = c.getLong(0),
|
||||||
|
calendarId = c.getLong(1),
|
||||||
|
beginMillis = c.getLong(2),
|
||||||
|
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
||||||
|
title = c.getString(4).orEmpty(),
|
||||||
|
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(6) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||||
|
if (eventIds.isEmpty()) return emptyMap()
|
||||||
|
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||||
|
// Batched: the ids go into the selection literally, and an unbounded
|
||||||
|
// `IN (...)` would grow the SQL past what SQLite takes.
|
||||||
|
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||||
|
context.contentResolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
REMINDER_PROJECTION,
|
||||||
|
"${CalendarContract.Reminders.METHOD} = " +
|
||||||
|
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||||
|
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Reminders.MINUTES),
|
||||||
|
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
||||||
|
null,
|
||||||
|
// One row is enough: the provider passes the sort order to SQLite.
|
||||||
|
"${CalendarContract.Reminders.MINUTES} DESC",
|
||||||
|
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val EVENT_ID_BATCH = 50
|
||||||
|
|
||||||
|
val OCCURRENCE_PROJECTION = arrayOf(
|
||||||
|
CalendarContract.Instances.EVENT_ID,
|
||||||
|
CalendarContract.Instances.CALENDAR_ID,
|
||||||
|
CalendarContract.Instances.BEGIN,
|
||||||
|
CalendarContract.Instances.END,
|
||||||
|
CalendarContract.Instances.TITLE,
|
||||||
|
CalendarContract.Instances.EVENT_LOCATION,
|
||||||
|
CalendarContract.Instances.ALL_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val REMINDER_PROJECTION = arrayOf(
|
||||||
|
CalendarContract.Reminders.EVENT_ID,
|
||||||
|
CalendarContract.Reminders.MINUTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
||||||
|
* survived. Finds nothing to do in the steady state; it exists for the device
|
||||||
|
* that quietly drops the alarm without a reboot to announce it (#75).
|
||||||
|
*/
|
||||||
|
object ReminderMaintenanceScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "reminder-scan-maintenance"
|
||||||
|
|
||||||
|
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
||||||
|
fun apply(context: Context) {
|
||||||
|
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||||
|
// The launch scan covers now; let the first periodic run wait.
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReminderMaintenanceWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun reminderScanner(): ReminderScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result = try {
|
||||||
|
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
.reminderScanner()
|
||||||
|
.scan()
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// The scan swallows its own failures, so anything reaching here is the
|
||||||
|
// entry point itself — a retry will not mend it.
|
||||||
|
Log.w(TAG, "Reminder maintenance scan failed", e)
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderMaintenance"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
@@ -28,17 +29,20 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one notification per due reminder alert on a dedicated channel.
|
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
* the event's detail screen.
|
||||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
*
|
||||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||||
* duplicating it.
|
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||||
|
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||||
|
* instead of stacking a second one.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val calendarPrefs: CalendarPrefs,
|
private val calendarPrefs: CalendarPrefs,
|
||||||
|
private val calendarDataSource: CalendarDataSource,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -49,12 +53,23 @@ class ReminderNotifier @Inject constructor(
|
|||||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun post(alert: ReminderAlert) {
|
/**
|
||||||
// The single choke point for the disabled-calendar gate: it covers both
|
* The single choke point for "this calendar is switched off", covering the
|
||||||
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||||
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
* before the switch-off, and a read-only install whose switch lives in
|
||||||
// longer notifies — without either receiver duplicating the check.
|
* [CalendarPrefs].
|
||||||
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
*/
|
||||||
|
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||||
|
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||||
|
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||||
|
* notification was put up, which the snooze re-show path uses to tell a
|
||||||
|
* silenced reminder from a delivered one.
|
||||||
|
*/
|
||||||
|
suspend fun post(alert: ReminderAlert): Boolean {
|
||||||
|
if (isSilenced(alert.calendarId)) return false
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
@@ -101,16 +116,18 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
}
|
}
|
||||||
|
// Handled either way — a retry hits the same revoked permission.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
fun cancel(alert: ReminderAlert) {
|
fun cancel(alert: ReminderAlert) {
|
||||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
@@ -123,7 +140,10 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||||
|
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||||
|
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||||
|
),
|
||||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass of in-house reminder delivery: read what is planned, post what has
|
||||||
|
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
|
||||||
|
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
|
||||||
|
* is owed, not the trigger.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderScanner @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val source: ReminderInstanceSource,
|
||||||
|
private val calendarDataSource: CalendarDataSource,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
|
private val alarms: ReminderAlarmScheduler,
|
||||||
|
private val state: ReminderStatePrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
||||||
|
// two passes can't both read the same watermark and post the same reminder.
|
||||||
|
private val scanLock = Mutex()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + io)
|
||||||
|
private val providerChanges = MutableSharedFlow<Unit>(
|
||||||
|
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
private var watching = false
|
||||||
|
|
||||||
|
suspend fun scan() = withContext(io) {
|
||||||
|
scanLock.withLock {
|
||||||
|
try {
|
||||||
|
runScan()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Permission revoked mid-flight; the next grant re-scans.
|
||||||
|
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Reminder scan failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runScan() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!hasReadCalendar()) return
|
||||||
|
|
||||||
|
if (!settingsPrefs.remindersEnabled.first()) {
|
||||||
|
// Reminders off: drop the wake-up, but keep the watermark moving
|
||||||
|
// so switching them back on doesn't replay the backlog.
|
||||||
|
alarms.cancelScan()
|
||||||
|
state.setLastScanMillis(now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||||
|
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||||
|
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||||
|
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||||
|
val planned = planReminders(
|
||||||
|
instances = occurrences,
|
||||||
|
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val schedule = scheduleReminders(
|
||||||
|
planned = planned,
|
||||||
|
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||||
|
nowMillis = now,
|
||||||
|
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (notifier.canPost()) {
|
||||||
|
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||||
|
}
|
||||||
|
// Advance even when nothing could be posted, so muting notifications
|
||||||
|
// doesn't build a backlog.
|
||||||
|
state.setLastScanMillis(now)
|
||||||
|
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
||||||
|
* alarm at once. Debounced, since a single save lands as several
|
||||||
|
* notifications. Process-lifetime only; other triggers cover the rest.
|
||||||
|
*/
|
||||||
|
fun startWatchingProvider() {
|
||||||
|
if (watching) return
|
||||||
|
watching = true
|
||||||
|
providerChanges
|
||||||
|
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
||||||
|
.onEach { scan() }
|
||||||
|
.launchIn(scope)
|
||||||
|
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
||||||
|
fun scanInBackground() {
|
||||||
|
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderScanner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead occurrences are read. Stretched further by the longest
|
||||||
|
* reminder offset in the table, so this is only the floor.
|
||||||
|
*/
|
||||||
|
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** How far back to look for occurrences that may still owe a reminder. */
|
||||||
|
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never wait longer than a day for the next pass: it rolls the lookahead
|
||||||
|
* window forward and re-arms an alarm the system may have dropped.
|
||||||
|
*/
|
||||||
|
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
|
||||||
|
* alarm, boot and package-replaced (both wipe pending alarms), and time or
|
||||||
|
* timezone changes (both move reminders relative to the armed alarm). All do the
|
||||||
|
* same thing, since [ReminderScanner.scan] is idempotent.
|
||||||
|
*
|
||||||
|
* Exported for the system broadcasts; an early scan triggered by another app is
|
||||||
|
* harmless.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var scanner: ReminderScanner
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// Checked despite every action doing the same thing: the receiver is
|
||||||
|
// exported and the broadcasts it takes are protected, so any other
|
||||||
|
// action did not come from where it claims to.
|
||||||
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
scanner.scan()
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
||||||
|
|
||||||
|
private val HANDLED_ACTIONS = setOf(
|
||||||
|
ACTION_SCAN,
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,13 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
|
||||||
|
* from [ReminderAlarmScheduler]'s single moving scan alarm: a snooze is pinned
|
||||||
|
* to one reminder and has to outlive the watermark moving past it, so it carries
|
||||||
|
* the reminder in its own intent.
|
||||||
*
|
*
|
||||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
|
||||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
* exact-alarm capability (API 31–32 with the permission revoked).
|
||||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
|
||||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
|
||||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
|
||||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderSnoozeScheduler @Inject constructor(
|
class ReminderSnoozeScheduler @Inject constructor(
|
||||||
@@ -37,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
|
|||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
||||||
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
// available without nagging for SCHEDULE_EXACT_ALARM.
|
||||||
alarmManager.setAndAllowWhileIdle(
|
alarmManager.setAndAllowWhileIdle(
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.MutablePreferences
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
|
||||||
import java.util.Base64
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Still relevant while the event has not ended: a reminder for an event that is
|
|
||||||
* already over is pointless to re-surface. Falls back to the begin time when the
|
|
||||||
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
|
||||||
*/
|
|
||||||
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
|
||||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local stash of reminder alerts that fired while their calendar was disabled
|
|
||||||
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
|
||||||
* (so the provider stops re-broadcasting the suppressed ones), which would
|
|
||||||
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
|
||||||
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
|
||||||
* the user re-enables the calendar before the event is over.
|
|
||||||
*
|
|
||||||
* Persisted in the shared preferences DataStore as a set of self-describing
|
|
||||||
* strings (one per alert); the stash never reaches a screen, so there is no
|
|
||||||
* domain model. Entries whose event has already ended are dropped on the next
|
|
||||||
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class SuppressedReminderStore @Inject constructor(
|
|
||||||
private val store: DataStore<Preferences>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
|
||||||
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
|
||||||
if (alerts.isEmpty()) return
|
|
||||||
store.edit { prefs ->
|
|
||||||
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
|
||||||
alerts.forEach { byId[it.alertId] = it }
|
|
||||||
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove and return the still-relevant stashed alerts belonging to any of
|
|
||||||
* [calendarIds]; drops expired entries for every calendar in passing.
|
|
||||||
*/
|
|
||||||
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
|
||||||
val recovered = mutableListOf<ReminderAlert>()
|
|
||||||
store.edit { prefs ->
|
|
||||||
val kept = decodeAll(prefs).filter { alert ->
|
|
||||||
when {
|
|
||||||
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
|
||||||
alert.calendarId in calendarIds -> { recovered += alert; false }
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.putStash(kept)
|
|
||||||
}
|
|
||||||
return recovered
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
|
||||||
suspend fun purgeExpired(nowMillis: Long) {
|
|
||||||
store.edit { prefs ->
|
|
||||||
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
|
||||||
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
|
||||||
|
|
||||||
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
|
||||||
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
|
||||||
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One stash entry as a delimited string. The '|' separator is safe because every
|
|
||||||
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
|
||||||
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
|
||||||
private const val FIELD_SEP = "|"
|
|
||||||
private const val NULL_LOCATION = "-"
|
|
||||||
|
|
||||||
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
|
||||||
alert.alertId.toString(),
|
|
||||||
alert.eventId.toString(),
|
|
||||||
alert.calendarId.toString(),
|
|
||||||
alert.beginMillis.toString(),
|
|
||||||
alert.endMillis.toString(),
|
|
||||||
if (alert.isAllDay) "1" else "0",
|
|
||||||
alert.title.toBase64(),
|
|
||||||
alert.location?.toBase64() ?: NULL_LOCATION,
|
|
||||||
).joinToString(FIELD_SEP)
|
|
||||||
|
|
||||||
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
|
||||||
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
|
||||||
val parts = raw.split(FIELD_SEP)
|
|
||||||
if (parts.size != 8) return null
|
|
||||||
return try {
|
|
||||||
ReminderAlert(
|
|
||||||
alertId = parts[0].toLong(),
|
|
||||||
eventId = parts[1].toLong(),
|
|
||||||
calendarId = parts[2].toLong(),
|
|
||||||
beginMillis = parts[3].toLong(),
|
|
||||||
endMillis = parts[4].toLong(),
|
|
||||||
title = parts[6].fromBase64(),
|
|
||||||
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
|
||||||
isAllDay = parts[5] == "1",
|
|
||||||
)
|
|
||||||
} catch (e: NumberFormatException) {
|
|
||||||
null
|
|
||||||
} catch (e: IllegalArgumentException) { // bad Base64
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toBase64(): String =
|
|
||||||
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
|
||||||
|
|
||||||
private fun String.fromBase64(): String =
|
|
||||||
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ways a calendar can behave unlike a plain, writable one — each a reason it
|
||||||
|
* is missing from the event and import pickers (#76).
|
||||||
|
*/
|
||||||
|
enum class CalendarStateLabel {
|
||||||
|
/**
|
||||||
|
* A special-dates mirror the app fills from contacts. Writable and visible,
|
||||||
|
* yet no event target: anything authored here is deleted by the next sync.
|
||||||
|
*/
|
||||||
|
MANAGED,
|
||||||
|
|
||||||
|
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||||
|
READ_ONLY,
|
||||||
|
|
||||||
|
/** The account holds the events, but this device isn't syncing them down. */
|
||||||
|
NOT_SYNCED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account keeps this calendar's events off the device
|
||||||
|
* (`Calendars.SYNC_EVENTS = 0`) — empty by construction. Device-local calendars
|
||||||
|
* are excluded: nothing syncs them by definition, and one from another app can
|
||||||
|
* hold real events at `sync_events = 0`.
|
||||||
|
*/
|
||||||
|
val CalendarSource.isNotSynced: Boolean
|
||||||
|
get() = !syncsEvents && !isLocal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a visibility switch on this calendar can change anything the user
|
||||||
|
* would see — it can't for a non-syncing one, with no events on the device.
|
||||||
|
*/
|
||||||
|
val CalendarSource.hasVisibilitySwitch: Boolean
|
||||||
|
get() = !isNotSynced
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this calendar can be offered as a target for a new or imported event.
|
||||||
|
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
||||||
|
* names on a manager row are exactly the states that keep a calendar out of them
|
||||||
|
* (#76). An event already living in an excluded calendar keeps it; the editor
|
||||||
|
* adds that calendar back to its picker.
|
||||||
|
*/
|
||||||
|
val CalendarSource.isEventTarget: Boolean
|
||||||
|
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
|
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||||
|
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-manager order within one group: the ones you can actually act on
|
||||||
|
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
||||||
|
* display-name ordering survives.
|
||||||
|
*/
|
||||||
|
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||||
|
sortedBy { it.isNotSynced }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
||||||
|
* set into the provider, plus the ids that need no write at all.
|
||||||
|
*/
|
||||||
|
data class CalendarVisibilityPlan(
|
||||||
|
val hide: Set<Long> = emptySet(),
|
||||||
|
val settled: Set<Long> = emptySet(),
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
|
||||||
|
* what the retired app-local visibility model left behind (#75) — against the
|
||||||
|
* calendars actually on the device.
|
||||||
|
*
|
||||||
|
* Only ever *hides*: switching a system-hidden calendar back on would un-hide it
|
||||||
|
* in every other calendar app too. Calendula follows the flag and explains
|
||||||
|
* itself once (see [hasSystemHiddenCalendars]).
|
||||||
|
*
|
||||||
|
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
|
||||||
|
* hidden, or gone from the device.
|
||||||
|
*/
|
||||||
|
fun calendarVisibilityPlan(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
pendingDisabledIds: Set<Long>,
|
||||||
|
): CalendarVisibilityPlan {
|
||||||
|
val byId = calendars.associateBy { it.id }
|
||||||
|
val hide = mutableSetOf<Long>()
|
||||||
|
val settled = mutableSetOf<Long>()
|
||||||
|
for (id in pendingDisabledIds) {
|
||||||
|
val calendar = byId[id]
|
||||||
|
// Gone from the device, or already invisible — nothing to write.
|
||||||
|
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
|
||||||
|
}
|
||||||
|
return CalendarVisibilityPlan(hide = hide, settled = settled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any calendar is switched off at system level without Calendula having
|
||||||
|
* asked for it — the condition the one-time notice explains.
|
||||||
|
*/
|
||||||
|
fun hasSystemHiddenCalendars(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
pendingDisabledIds: Set<Long>,
|
||||||
|
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlin.math.abs
|
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||||
import kotlin.math.atan2
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import kotlin.math.cbrt
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
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.
|
||||||
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
|
|||||||
* (#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. The picker softens every swatch through [pastelArgb]:
|
* the raw provider value — and it gets that colour from the same [eventTone]
|
||||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
||||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
* Because a harmonised container pins lightness, the raw palette's lightness
|
||||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
* axis is invisible on screen: two raw colours that look different — a navy and
|
||||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
||||||
* space, as before, left near-identical painted swatches and stranded the
|
* paints as the same grey. Judging distinctness in raw space, as before, left
|
||||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
* near-identical painted swatches and stranded the neutrals as a run of
|
||||||
|
* 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 washed-out
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
||||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
||||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
||||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
* distinct colours: most vivid first, a colour is kept only when at least
|
||||||
* colour already kept. Small palettes are already curated by their adapter
|
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
||||||
* and pass through whole.
|
* palettes are already curated by their adapter 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.
|
||||||
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
|
|||||||
*/
|
*/
|
||||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
val painted = sortedBy { it.key }
|
val painted = sortedBy { it.key }
|
||||||
.distinctBy { pastelArgb(it.argb) }
|
.distinctBy { paintedArgb(it.argb) }
|
||||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
.map { it to oklchOf(paintedArgb(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 { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||||
}
|
}
|
||||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
}
|
}
|
||||||
@@ -61,17 +58,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, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
if (swatches.size < 2) return swatches
|
if (swatches.size < 2) return swatches
|
||||||
val byHue = swatches.sortedWith(
|
val byHue = swatches.sortedWith(
|
||||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.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 = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
var widestGap = 360f - 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) {
|
||||||
@@ -84,102 +81,32 @@ 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, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
val byVividness = swatches
|
val byVividness = swatches
|
||||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
.sortedWith(
|
||||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||||
|
.thenBy { it.first.key },
|
||||||
|
)
|
||||||
|
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||||
for (candidate in byVividness) {
|
for (candidate in byVividness) {
|
||||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||||
}
|
}
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The colour the picker paints for [argb]; the light theme stands in as the
|
||||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
* reference, since a harmonised container differs only in lightness by theme
|
||||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
* and curation compares hue and chroma. */
|
||||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
private fun paintedArgb(argb: Int): Int =
|
||||||
* curation is theme-independent — only hue and saturation distinguish painted
|
eventTone(argb, dark = false, harmonise = true).container
|
||||||
* 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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
||||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
* share one lightness, so this is really a hue/chroma separation — far enough
|
||||||
* is dropped rather than shown as pale filler.
|
* apart that two swatches never read as the same colour in the grid.
|
||||||
*/
|
*/
|
||||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
private const val MIN_DISTANCE = 0.025f
|
||||||
|
|
||||||
/** 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)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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,7 +15,9 @@ 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 one-hour duration. [EventForm.calendarId] is left null so
|
* next full hour, and a placeholder hour for a missing end that the form then
|
||||||
|
* 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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
data class CalendarSource(
|
data class CalendarSource(
|
||||||
@@ -8,6 +12,11 @@ data class CalendarSource(
|
|||||||
val accountName: String,
|
val accountName: String,
|
||||||
val accountType: String,
|
val accountType: String,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
|
/**
|
||||||
|
* The system's `Calendars.VISIBLE` flag — the single visibility model,
|
||||||
|
* deciding both what Calendula shows and whether this calendar plans
|
||||||
|
* reminders (#75). The drawer's filter sheet is a separate in-app declutter.
|
||||||
|
*/
|
||||||
val isVisibleInSystem: Boolean,
|
val isVisibleInSystem: Boolean,
|
||||||
/**
|
/**
|
||||||
* Whether events in this calendar can be created/edited/deleted
|
* Whether events in this calendar can be created/edited/deleted
|
||||||
@@ -34,6 +43,13 @@ data class CalendarSource(
|
|||||||
* even after a backup restore clears the app's stored ids.
|
* even after a backup restore clears the app's stored ids.
|
||||||
*/
|
*/
|
||||||
val isManaged: Boolean = false,
|
val isManaged: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether the provider keeps this calendar's events on the device
|
||||||
|
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
|
||||||
|
* nothing about device-local calendars, which can hold events with it off.
|
||||||
|
* Read for the "not synced" row label (#76).
|
||||||
|
*/
|
||||||
|
val syncsEvents: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -46,6 +62,12 @@ data class EventInstance(
|
|||||||
val isAllDay: Boolean,
|
val isAllDay: Boolean,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val location: String?,
|
val location: String?,
|
||||||
|
/**
|
||||||
|
* Whether this row's event carries a recurrence rule. Only search results
|
||||||
|
* (which read the series master) fill this in — the Instances query already
|
||||||
|
* yields one row per occurrence, so it stays false there.
|
||||||
|
*/
|
||||||
|
val isRecurring: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +78,31 @@ data class EventInstance(
|
|||||||
*/
|
*/
|
||||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||||
|
* events, UTC for all-day ones, whose midnights would otherwise shift day
|
||||||
|
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
|
||||||
|
*/
|
||||||
|
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||||
|
if (isAllDay) TimeZone.UTC else zone
|
||||||
|
|
||||||
|
/** The first calendar day this event occupies. */
|
||||||
|
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||||
|
start.toLocalDateTime(dateZone(zone)).date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last calendar day this event occupies. An event ending exactly at midnight
|
||||||
|
* does not reach into that day, so resolve just before [EventInstance.end].
|
||||||
|
*/
|
||||||
|
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||||
|
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||||
|
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||||
|
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||||
|
spanFirstDay(zone) != spanLastDay(zone)
|
||||||
|
|
||||||
data class EventDetail(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
@@ -83,6 +130,12 @@ 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()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
internal 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",
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||||
|
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
|
||||||
|
* kept to the shapes the picker can build; the provider stays the authority.
|
||||||
|
*
|
||||||
|
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
|
||||||
|
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
|
||||||
|
* start day doesn't exist in rather than clamping; a weekly rule repeats in
|
||||||
|
* blocks of `interval` weeks beginning on Monday (the default WKST, since
|
||||||
|
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
|
||||||
|
* [RecurrenceEnd.Until] is inclusive.
|
||||||
|
*
|
||||||
|
* Returns fewer than [limit] dates when the series ends first, and an empty list
|
||||||
|
* only when the rule yields nothing at all (an UNTIL before [start]).
|
||||||
|
*/
|
||||||
|
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||||
|
if (limit <= 0) return emptyList()
|
||||||
|
val until = (end as? RecurrenceEnd.Until)?.date
|
||||||
|
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||||
|
val wanted = minOf(limit, maxCount)
|
||||||
|
if (wanted <= 0) return emptyList()
|
||||||
|
if (until != null && start > until) return emptyList()
|
||||||
|
|
||||||
|
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
|
||||||
|
// and let the walk skip anything landing on or before it.
|
||||||
|
val result = mutableListOf(start)
|
||||||
|
var period = 0
|
||||||
|
// Periods can yield nothing (a skipped 31st), so the cap counts periods
|
||||||
|
// examined rather than dates found.
|
||||||
|
while (result.size < wanted && period < MAX_PERIODS) {
|
||||||
|
for (date in occurrencesInPeriod(period, start)) {
|
||||||
|
if (date <= start) continue
|
||||||
|
if (until != null && date > until) return result
|
||||||
|
result += date
|
||||||
|
if (result.size == wanted) return result
|
||||||
|
}
|
||||||
|
period++
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||||
|
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||||
|
when (freq) {
|
||||||
|
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||||
|
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||||
|
RecurrenceFreq.Monthly -> {
|
||||||
|
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||||
|
// plus() clamps into the shorter month, but the rule skips such a
|
||||||
|
// period — so a clamped date means "not this month".
|
||||||
|
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||||
|
}
|
||||||
|
RecurrenceFreq.Yearly ->
|
||||||
|
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One weekly block: every picked weekday inside the week that begins
|
||||||
|
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||||
|
* no picks the rule simply repeats the start's weekday.
|
||||||
|
*/
|
||||||
|
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||||
|
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||||
|
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||||
|
DAYS_PER_WEEK
|
||||||
|
val weekStart = start
|
||||||
|
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||||
|
.plus(period * interval, DateTimeUnit.WEEK)
|
||||||
|
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||||
|
weekStart.plus(
|
||||||
|
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a run of [occurrences] starting at [start] leaves its starting year,
|
||||||
|
* i.e. whether showing them without a year would be ambiguous — a yearly rule
|
||||||
|
* would otherwise read as the same date repeated.
|
||||||
|
*/
|
||||||
|
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||||
|
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||||
|
|
||||||
|
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||||
|
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||||
|
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||||
|
|
||||||
|
private const val DAYS_PER_WEEK = 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many repetitions to examine before giving up: generous enough for the
|
||||||
|
* sparsest rule the picker can build, bounded so a rule whose occurrences all
|
||||||
|
* fall outside its own UNTIL can't spin.
|
||||||
|
*/
|
||||||
|
private const val MAX_PERIODS = 2_000
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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(";")
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.reminders
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
|
||||||
|
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
|
||||||
|
data class ReminderEventInstance(
|
||||||
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occurrence paired with one of its reminder offsets, and the instant that
|
||||||
|
* pairing has to fire at.
|
||||||
|
*/
|
||||||
|
data class PlannedReminder(
|
||||||
|
val instance: ReminderEventInstance,
|
||||||
|
val minutes: Int,
|
||||||
|
val alarmMillis: Long,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||||
|
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||||
|
*/
|
||||||
|
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||||
|
var h = eventId * 1_000_003L
|
||||||
|
h = (h xor beginMillis) * 31L
|
||||||
|
return h + minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||||
|
data class ReminderSchedule(
|
||||||
|
val due: List<PlannedReminder>,
|
||||||
|
val nextAlarmMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair every instance with each of its event's reminder offsets.
|
||||||
|
*
|
||||||
|
* Timed occurrences fire at `begin − minutes`. All-day ones read the offset only
|
||||||
|
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||||
|
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||||
|
* Duplicate offsets in [minutesByEvent] collapse.
|
||||||
|
*/
|
||||||
|
fun planReminders(
|
||||||
|
instances: List<ReminderEventInstance>,
|
||||||
|
minutesByEvent: Map<Long, List<Int>>,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||||
|
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
||||||
|
PlannedReminder(
|
||||||
|
instance = instance,
|
||||||
|
minutes = minutes,
|
||||||
|
alarmMillis = if (instance.isAllDay) {
|
||||||
|
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||||
|
} else {
|
||||||
|
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||||
|
private fun allDayDate(beginMillis: Long): LocalDate =
|
||||||
|
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many whole days before its occurrence a raw all-day offset means.
|
||||||
|
*
|
||||||
|
* Our own rows fold the wanted hour into the offset, so the local date of the
|
||||||
|
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
|
||||||
|
* time and taken at face value instead — unless the instant lands on the hour the
|
||||||
|
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
|
||||||
|
* encodings collide and the tie goes to our own reading.
|
||||||
|
*
|
||||||
|
* Also used by
|
||||||
|
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
|
||||||
|
* display, so screen and notification agree.
|
||||||
|
*/
|
||||||
|
internal fun allDayLeadDays(
|
||||||
|
rawMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
||||||
|
val minutesFromNamedHour = encoded.toLocalTime().let {
|
||||||
|
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
||||||
|
minOf(delta, MINUTES_PER_DAY - delta)
|
||||||
|
}
|
||||||
|
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
||||||
|
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
||||||
|
}
|
||||||
|
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
||||||
|
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
||||||
|
|
||||||
|
private fun allDayAlarmMillis(
|
||||||
|
beginMillis: Long,
|
||||||
|
rawMinutes: Int,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): Long = allDayDate(beginMillis)
|
||||||
|
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||||
|
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||||
|
.atZone(zone)
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split [planned] into what is due now and when to wake up next.
|
||||||
|
*
|
||||||
|
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
||||||
|
* running twice cannot post twice while a late one still catches up. Reminders
|
||||||
|
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||||
|
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||||
|
*/
|
||||||
|
fun scheduleReminders(
|
||||||
|
planned: List<PlannedReminder>,
|
||||||
|
lastFiredMillis: Long,
|
||||||
|
nowMillis: Long,
|
||||||
|
horizonMillis: Long,
|
||||||
|
): ReminderSchedule {
|
||||||
|
val due = planned
|
||||||
|
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||||
|
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||||
|
.distinctBy { it.key }
|
||||||
|
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||||
|
val nextPending = planned
|
||||||
|
.filter { it.alarmMillis > nowMillis }
|
||||||
|
.minOfOrNull { it.alarmMillis }
|
||||||
|
return ReminderSchedule(
|
||||||
|
due = due,
|
||||||
|
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Still worth showing while the occurrence has not ended. Falls back to the
|
||||||
|
* begin time when the end is unknown (0L).
|
||||||
|
*/
|
||||||
|
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
||||||
|
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
|
||||||
|
* claims the present rather than replaying everything since the epoch; a
|
||||||
|
* watermark in the future (clock moved back) is clamped so it can't silence
|
||||||
|
* every reminder until real time catches up.
|
||||||
|
*/
|
||||||
|
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||||
|
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead instances must be queried: the plain lookahead plus the longest
|
||||||
|
* reminder offset, so a "two weeks before" is planned before it comes due. The
|
||||||
|
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
|
||||||
|
* largest row in the whole provider, and a nonsense one would otherwise make
|
||||||
|
* every scan expand every series over years.
|
||||||
|
*/
|
||||||
|
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
||||||
|
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||||
|
|
||||||
|
/** Longest reminder offset a scan stretches its query window for — one year. */
|
||||||
|
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
||||||
@@ -10,6 +10,7 @@ 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
|
||||||
@@ -23,9 +24,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
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
|
||||||
@@ -74,6 +80,7 @@ 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 = {},
|
||||||
@@ -148,6 +155,10 @@ fun CalendarHost(
|
|||||||
// over Settings and survives view switches.
|
// over Settings and survives view switches.
|
||||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Backup & restore (#69) — hoisted like the manager, being driven by the
|
||||||
|
// calendar list rather than by preferences. Reached from both.
|
||||||
|
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||||
@@ -198,7 +209,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 = ImportSource.Insert
|
importFormSource = requestedInsertSource
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -210,6 +221,7 @@ fun CalendarHost(
|
|||||||
fun dismissCoveringOverlays() {
|
fun dismissCoveringOverlays() {
|
||||||
showSettings = false
|
showSettings = false
|
||||||
showCalendars = false
|
showCalendars = false
|
||||||
|
showBackup = false
|
||||||
detailKey = null
|
detailKey = null
|
||||||
editKey = null
|
editKey = null
|
||||||
importUri = null
|
importUri = null
|
||||||
@@ -287,14 +299,37 @@ 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
|
||||||
|
}
|
||||||
|
val moveScope = remember(movableCalendarIds, reschedule) {
|
||||||
|
EventMoveScope(
|
||||||
|
movableCalendarIds = movableCalendarIds,
|
||||||
|
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
|
||||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
// so back there falls through to the system and exits the app.
|
// so back there falls through to the system and exits the app.
|
||||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
editKey != null || showSettings || showCalendars || showBackup ||
|
||||||
importForm != null
|
importUri != null || importForm != null
|
||||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||||
viewStack = viewStack.dropLast(1)
|
viewStack = viewStack.dropLast(1)
|
||||||
}
|
}
|
||||||
@@ -304,6 +339,7 @@ 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 },
|
||||||
@@ -360,6 +396,11 @@ 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.
|
||||||
@@ -415,6 +456,7 @@ fun CalendarHost(
|
|||||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +476,7 @@ fun CalendarHost(
|
|||||||
editKey = null
|
editKey = null
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,18 +490,7 @@ fun CalendarHost(
|
|||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
onOpenBackup = { showBackup = true },
|
||||||
}
|
|
||||||
|
|
||||||
// Calendar manager — slides over Settings.
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showCalendars,
|
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
|
||||||
) {
|
|
||||||
CalendarsScreen(
|
|
||||||
onBack = { showCalendars = false },
|
|
||||||
onImport = { importUri = it; importForceMany = true },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +501,7 @@ fun CalendarHost(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
@@ -483,6 +516,40 @@ fun CalendarHost(
|
|||||||
initialFormSource = importFormSource,
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared last so it covers every overlay that can open it: Settings,
|
||||||
|
// both event forms, and the .ics import picker (#76).
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showCalendars,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
CalendarsScreen(
|
||||||
|
onBack = { showCalendars = false },
|
||||||
|
onOpenBackup = { showBackup = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup & restore — over the manager, since the manager links into it.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showBackup,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
BackupScreen(
|
||||||
|
onBack = { showBackup = false },
|
||||||
|
// Restore runs the normal .ics import, and both this screen and
|
||||||
|
// the manager that can have opened it are declared above the
|
||||||
|
// import overlays — so both have to step aside.
|
||||||
|
onImport = {
|
||||||
|
importUri = it
|
||||||
|
importForceMany = true
|
||||||
|
showBackup = false
|
||||||
|
showCalendars = false
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -21,6 +22,8 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
@@ -36,6 +39,8 @@ 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 = {},
|
||||||
@@ -48,6 +53,10 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A launch scan already covers the app coming up with the permission, so
|
||||||
|
// only a grant made during this session owes a re-scan.
|
||||||
|
val grantedAtLaunch = remember { hasPermission }
|
||||||
|
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
val obs = LifecycleEventObserver { _, event ->
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
@@ -78,6 +87,19 @@ fun RootScreen(
|
|||||||
// frame instead of flashing the wrong screen.
|
// frame instead of flashing the wrong screen.
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
|
// One-time explainer for the switch to the device's own calendar
|
||||||
|
// visibility (#75), armed by the reconciler.
|
||||||
|
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||||
|
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||||
|
// Runs on entry however the permission was granted, including via
|
||||||
|
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
visibilityNotice.reconcile()
|
||||||
|
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||||
|
}
|
||||||
|
if (onboardingDone == true && noticePending) {
|
||||||
|
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||||
|
}
|
||||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
when (done) {
|
when (done) {
|
||||||
true -> CalendarHost(
|
true -> CalendarHost(
|
||||||
@@ -89,6 +111,7 @@ fun RootScreen(
|
|||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
requestedInsertSource = requestedInsertSource,
|
||||||
onInsertConsumed = onInsertConsumed,
|
onInsertConsumed = onInsertConsumed,
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
|
|||||||
@@ -92,20 +92,27 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
|||||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||||
* year shown too only when it differs from the end's.
|
* year shown too only when it differs from the end's.
|
||||||
|
*
|
||||||
|
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start – end" form as well.
|
||||||
|
* The agenda's own header browses the whole month, so the month name is right
|
||||||
|
* there; the range picker previews the window an option opens *today*, which for
|
||||||
|
* "This month" is only the rest of it.
|
||||||
*/
|
*/
|
||||||
fun agendaRangeWindowSummary(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
start: LocalDate,
|
start: LocalDate,
|
||||||
end: LocalDate,
|
end: LocalDate,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
monthAsSpan: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
return when (range) {
|
return when {
|
||||||
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
range == AgendaRange.ThisMonth && !monthAsSpan ->
|
||||||
|
localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
else -> {
|
else -> {
|
||||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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
|
||||||
@@ -137,7 +138,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(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ fun AgendaScreen(
|
|||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -111,6 +112,7 @@ 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)
|
||||||
@@ -198,6 +200,7 @@ fun AgendaScreen(
|
|||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
description = stringResource(R.string.agenda_range_override_hint),
|
description = stringResource(R.string.agenda_range_override_hint),
|
||||||
selected = successState?.range ?: AgendaRange.Month,
|
selected = successState?.range ?: AgendaRange.Month,
|
||||||
|
weekStart = weekStart,
|
||||||
onSelect = viewModel::setRangeOverride,
|
onSelect = viewModel::setRangeOverride,
|
||||||
onDismiss = { showRangePicker = false },
|
onDismiss = { showRangePicker = false },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,41 +2,14 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
|
||||||
* The zone the event's calendar dates live in. Timed events are resolved in the
|
|
||||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
|
||||||
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
|
||||||
* a one-day event onto its next day. Matches the Week view and detail card.
|
|
||||||
*/
|
|
||||||
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
|
||||||
if (isAllDay) TimeZone.UTC else zone
|
|
||||||
|
|
||||||
/** The first calendar day this event occupies. */
|
|
||||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
|
||||||
start.toLocalDateTime(dateZone(zone)).date
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last calendar day this event actually occupies. An event ending exactly at
|
|
||||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
|
||||||
* into that boundary day, so resolve the instant just before [end].
|
|
||||||
*/
|
|
||||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
|
||||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
|
||||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
|
||||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
|
||||||
spanFirstDay(zone) != spanLastDay(zone)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an agenda row's time line should convey for an event on a given day —
|
* What an agenda row's time line should convey for an event on a given day —
|
||||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ class AgendaViewModel @Inject constructor(
|
|||||||
settingsPrefs.agendaShowRangeBar,
|
settingsPrefs.agendaShowRangeBar,
|
||||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
// First day of the week, for the calendar-aligned "this week" range.
|
/**
|
||||||
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||||
|
* because the range picker resolves each option to real dates, which needs
|
||||||
|
* the same week start the window is built from.
|
||||||
|
*/
|
||||||
|
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How to treat events that already ended today (show / dim / hide). A display
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
@@ -90,7 +94,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> =
|
val state: StateFlow<AgendaUiState> =
|
||||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||||
AgendaParams(
|
AgendaParams(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
range = override ?: settings.range,
|
range = override ?: settings.range,
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
||||||
|
// under several mimes, so accept the common set.
|
||||||
|
private val RESTORE_MIME_TYPES = arrayOf(
|
||||||
|
"text/calendar",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||||
|
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||||
|
*
|
||||||
|
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||||
|
* [onImport] hands a picked file to the app's normal .ics import flow.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BackupScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onImport: (Uri) -> Unit,
|
||||||
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Export covers local calendars only; managed special-dates mirrors are
|
||||||
|
// rebuilt from contacts. Restore can target anything the import picker offers.
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
|
||||||
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
|
// own launcher.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||||
|
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Restore runs the picked file through the normal .ics import flow.
|
||||||
|
val openBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> uri?.let(onImport) }
|
||||||
|
|
||||||
|
// The VM persists the write grant so background runs can keep writing.
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||||
|
var showInterval by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
|
LaunchedEffect(backupResult) {
|
||||||
|
when (val r = backupResult) {
|
||||||
|
is BackupResult.Success -> {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
viewModel.consumeBackupResult()
|
||||||
|
}
|
||||||
|
BackupResult.Failure -> {
|
||||||
|
snackbarHostState.showSnackbar(backupFailedText)
|
||||||
|
viewModel.consumeBackupResult()
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_backup),
|
||||||
|
onBack = onBack,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
|
||||||
|
if (exportable.isNotEmpty()) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
// A single exportable calendar skips the selector.
|
||||||
|
if (exportable.size == 1) {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = stringResource(R.string.calendars_restore_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showInterval = true },
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
} else if (canImport) {
|
||||||
|
// Nothing to back up, but restore is still possible — don't hide
|
||||||
|
// it behind export eligibility.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(stringResource(R.string.calendars_restore_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExportPicker) {
|
||||||
|
ExportCalendarPicker(
|
||||||
|
calendars = exportable,
|
||||||
|
onExport = viewModel::exportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
|
* the picked file with the chosen calendar ids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExportCalendarPicker(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
onExport: (Uri, Set<Long>?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Deliberately not keyed on [calendars]: that list is observer-driven, so
|
||||||
|
// keying it would reset the user's de-selections on every provider re-emit.
|
||||||
|
var selected by rememberSaveable(
|
||||||
|
stateSaver = listSaver(
|
||||||
|
save = { it.toList() },
|
||||||
|
restore = { it.toSet() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
mutableStateOf(calendars.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
onExport(uri, selected)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.calendars_export_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_export_hint))
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id in selected
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, calendars.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
enabled = selected.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.calendars_export_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberFolderName(uriString: String?): String? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(uriString) {
|
||||||
|
uriString?.let {
|
||||||
|
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||||
|
@Composable
|
||||||
|
private fun backupIntervalLabel(minutes: Long): String {
|
||||||
|
val duration = when {
|
||||||
|
minutes % MINUTES_PER_WEEK == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||||
|
minutes % MINUTES_PER_DAY == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||||
|
minutes % 60L == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||||
|
else ->
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||||
|
}
|
||||||
|
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||||
|
@Composable
|
||||||
|
private fun backupStatusText(status: BackupStatus): String {
|
||||||
|
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||||
|
val relative = DateUtils.getRelativeTimeSpanString(
|
||||||
|
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString()
|
||||||
|
return if (status.lastSuccess) {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||||
|
@Composable
|
||||||
|
private fun BackupIntervalDialog(
|
||||||
|
currentMinutes: Long,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Pick the largest unit the current value divides into.
|
||||||
|
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||||
|
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||||
|
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||||
|
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||||
|
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||||
|
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||||
|
onDismiss()
|
||||||
|
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTES_PER_DAY = 1_440L
|
||||||
|
private const val MINUTES_PER_WEEK = 10_080L
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -26,25 +23,22 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Backup
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.FileUpload
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -66,83 +60,69 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||||
|
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||||
|
import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.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
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
|
|
||||||
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
|
||||||
// mimes depending on the source app (our own export uses text/calendar; others
|
|
||||||
// hand them out as octet-stream or text/plain), so accept the common set rather
|
|
||||||
// than hide valid backups behind an over-tight filter.
|
|
||||||
private val RESTORE_MIME_TYPES = arrayOf(
|
|
||||||
"text/calendar",
|
|
||||||
"application/octet-stream",
|
|
||||||
"text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
* calendars with create / rename / recolor / delete, and synced calendars
|
||||||
* and lists synced calendars read-only with a per-account "manage in the source
|
* read-only with a per-account "manage in the source app" deep-link.
|
||||||
* app" deep-link — the app never touches a synced calendar's server. A
|
*
|
||||||
* full-screen destination; [onBack] pops it.
|
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
||||||
|
* screen only points at it. [onBack] pops the destination.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onImport: (android.net.Uri) -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
// [editorSession] bumps on every open so the editor's field state resets for
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -158,6 +138,7 @@ fun CalendarsScreen(
|
|||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
|
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
if (id == null || id == NEW_CALENDAR_ID) {
|
if (id == null || id == NEW_CALENDAR_ID) {
|
||||||
@@ -177,22 +158,14 @@ fun CalendarsScreen(
|
|||||||
CalendarsList(
|
CalendarsList(
|
||||||
local = calendars.filter { it.isLocal },
|
local = calendars.filter { it.isLocal },
|
||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
disabledIds = disabledIds,
|
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
backupResult = backupResult,
|
onOpenBackup = onOpenBackup,
|
||||||
onExportBackup = viewModel::exportBackup,
|
|
||||||
onImport = onImport,
|
|
||||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
|
||||||
autoBackup = autoBackup,
|
|
||||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
|
||||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
|
||||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
onSetDisabled = viewModel::setDisabled,
|
onSetVisible = viewModel::setCalendarVisible,
|
||||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
onSetAccountVisible = viewModel::setAccountVisible,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,28 +174,20 @@ fun CalendarsScreen(
|
|||||||
private fun CalendarsList(
|
private fun CalendarsList(
|
||||||
local: List<CalendarSource>,
|
local: List<CalendarSource>,
|
||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
disabledIds: Set<Long>,
|
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
backupResult: BackupResult?,
|
onOpenBackup: () -> Unit,
|
||||||
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
|
||||||
onImport: (android.net.Uri) -> Unit,
|
|
||||||
onConsumeBackupResult: () -> Unit,
|
|
||||||
autoBackup: AutoBackupUiState,
|
|
||||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
|
||||||
onSetAutoBackupInterval: (Long) -> Unit,
|
|
||||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
onSetDisabled: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||||
// calendar visible by default, the section is collapsible for tidiness).
|
// calendar visible by default, the section is collapsible for tidiness).
|
||||||
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
|
||||||
var localExpanded by remember { mutableStateOf(true) }
|
var localExpanded by remember { mutableStateOf(true) }
|
||||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
@@ -233,47 +198,6 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
|
||||||
// to the VM to stream the .ics into. This launcher exports everything
|
|
||||||
// eligible (null); the per-calendar selector owns its own launcher.
|
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
|
||||||
) { uri -> uri?.let { onExportBackup(it, null) } }
|
|
||||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// SAF "open document" picker for restoring events from a .ics file. The
|
|
||||||
// picked Uri is handed up to the host, which runs it through the same import
|
|
||||||
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
|
||||||
val openBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
) { uri -> uri?.let(onImport) }
|
|
||||||
|
|
||||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
|
||||||
// write grant so background runs can keep writing to it.
|
|
||||||
val pickFolder = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
|
||||||
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
|
||||||
var showInterval by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
|
||||||
LaunchedEffect(backupResult) {
|
|
||||||
when (val r = backupResult) {
|
|
||||||
is BackupResult.Success -> {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
context.resources.getQuantityString(
|
|
||||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
onConsumeBackupResult()
|
|
||||||
}
|
|
||||||
BackupResult.Failure -> {
|
|
||||||
snackbarHostState.showSnackbar(backupFailedText)
|
|
||||||
onConsumeBackupResult()
|
|
||||||
}
|
|
||||||
null -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -281,12 +205,12 @@ private fun CalendarsList(
|
|||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_disable_hint))
|
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||||
|
|
||||||
// Local (device-only) calendars — one collapsible group. The header's
|
// Local (device-only) calendars — one collapsible group. The header's
|
||||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||||
// tapping a calendar row opens its editor.
|
// tapping a calendar row opens its editor.
|
||||||
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
val localDisabled = local.isNotEmpty() && local.none { it.isVisibleInSystem }
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = stringResource(R.string.calendars_local_header),
|
title = stringResource(R.string.calendars_local_header),
|
||||||
expanded = localExpanded,
|
expanded = localExpanded,
|
||||||
@@ -298,17 +222,17 @@ private fun CalendarsList(
|
|||||||
onManage = onAdd,
|
onManage = onAdd,
|
||||||
onToggleExpand = { localExpanded = !localExpanded },
|
onToggleExpand = { localExpanded = !localExpanded },
|
||||||
showToggleAll = local.isNotEmpty(),
|
showToggleAll = local.isNotEmpty(),
|
||||||
allEnabled = local.none { it.id in disabledIds },
|
allEnabled = local.all { it.isVisibleInSystem },
|
||||||
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||||
) {
|
) {
|
||||||
if (local.isEmpty()) {
|
if (local.isEmpty()) {
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
} else {
|
} else {
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = calendar.id in disabledIds
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
@@ -317,7 +241,7 @@ private fun CalendarsList(
|
|||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName,
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onEdit(calendar) },
|
onClick = { onEdit(calendar) },
|
||||||
@@ -326,82 +250,22 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup — local calendars have no sync, so a .ics export is their only
|
// Pointer to the Backup entry (#69), so it stays findable from here.
|
||||||
// safety net. Offered only when there is something exportable: the user's
|
Spacer(Modifier.height(16.dp))
|
||||||
// own local calendars (managed special-dates mirrors don't count).
|
GroupedRow(
|
||||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
title = stringResource(R.string.settings_section_backup),
|
||||||
// Restore/import can target any writable, non-managed calendar (local or
|
summary = stringResource(R.string.settings_backup_subtitle),
|
||||||
// synced), so its availability is broader than export's.
|
position = Position.Alone,
|
||||||
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
leading = { LeadingAvatar(Icons.Default.Backup) },
|
||||||
if (exportable.isNotEmpty()) {
|
trailing = {
|
||||||
Spacer(Modifier.height(16.dp))
|
Icon(
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
contentDescription = null,
|
||||||
// One connected card: the one-time export on top, then automatic
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
// backup (and its folder/interval rows when on).
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
|
||||||
position = Position.Top,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
|
||||||
onClick = {
|
|
||||||
// With more than one exportable calendar, let the user choose
|
|
||||||
// which to include; a single one exports straight away.
|
|
||||||
if (exportable.size == 1) {
|
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
|
||||||
} else {
|
|
||||||
showExportPicker = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
summary = stringResource(R.string.calendars_restore_hint),
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = {
|
|
||||||
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
|
||||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
|
||||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
|
||||||
)
|
|
||||||
if (autoBackup.enabled) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
|
||||||
summary = rememberFolderName(autoBackup.folderUri)
|
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
|
||||||
)
|
)
|
||||||
GroupedRow(
|
},
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
onClick = onOpenBackup,
|
||||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
)
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showInterval = true },
|
|
||||||
)
|
|
||||||
HintText(backupStatusText(autoBackup.status))
|
|
||||||
}
|
|
||||||
} else if (canImport) {
|
|
||||||
// Nothing to back up (no writable local calendar), but events can
|
|
||||||
// still be restored into a writable calendar — offer restore on its
|
|
||||||
// own so it isn't hidden behind export eligibility.
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
|
||||||
HintText(stringResource(R.string.calendars_restore_hint))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -411,14 +275,19 @@ private fun CalendarsList(
|
|||||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
HintText(stringResource(R.string.calendars_synced_hint))
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
synced
|
synced
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
.groupByAccount()
|
||||||
.forEach { (account, cals) ->
|
.forEach { group ->
|
||||||
val expanded = account !in collapsedAccounts
|
val cals = group.calendars
|
||||||
val accountType = cals.first().accountType
|
val expanded = group.key !in collapsedAccounts
|
||||||
val accountDisabled = cals.all { it.id in disabledIds }
|
val accountType = group.accountType
|
||||||
|
// A non-syncing calendar has no switch, so it neither counts
|
||||||
|
// towards "the whole account is off" nor moves with toggle-all.
|
||||||
|
val switchable = cals.filter { it.hasVisibilitySwitch }
|
||||||
|
val accountDisabled = switchable.isNotEmpty() &&
|
||||||
|
switchable.none { it.isVisibleInSystem }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = account,
|
title = accountGroupTitle(group),
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
bodyHasRows = true,
|
bodyHasRows = true,
|
||||||
headerDisabled = accountDisabled,
|
headerDisabled = accountDisabled,
|
||||||
@@ -430,29 +299,41 @@ private fun CalendarsList(
|
|||||||
},
|
},
|
||||||
onToggleExpand = {
|
onToggleExpand = {
|
||||||
collapsedAccounts = if (expanded) {
|
collapsedAccounts = if (expanded) {
|
||||||
collapsedAccounts + account
|
collapsedAccounts + group.key
|
||||||
} else {
|
} else {
|
||||||
collapsedAccounts - account
|
collapsedAccounts - group.key
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showToggleAll = true,
|
showToggleAll = switchable.isNotEmpty(),
|
||||||
allEnabled = cals.none { it.id in disabledIds },
|
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||||
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
onToggleAll = { enabled ->
|
||||||
|
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
cals.forEachIndexed { index, calendar ->
|
// Actionable calendars first; non-syncing ones at the
|
||||||
val disabled = calendar.id in disabledIds
|
// bottom, dimmed and switchless.
|
||||||
|
val ordered = cals.orderedForManager()
|
||||||
|
ordered.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
summary = calendarRowSummary(calendar),
|
||||||
|
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
EnableSwitch(
|
{
|
||||||
calendarName = calendar.displayName,
|
EnableSwitch(
|
||||||
enabled = !disabled,
|
calendarName = calendar.displayName,
|
||||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
enabled = calendar.isVisibleInSystem,
|
||||||
)
|
onToggle = { enabled ->
|
||||||
|
onSetVisible(calendar.id, enabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -460,93 +341,6 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showInterval) {
|
|
||||||
BackupIntervalDialog(
|
|
||||||
currentMinutes = autoBackup.intervalMinutes,
|
|
||||||
onConfirm = onSetAutoBackupInterval,
|
|
||||||
onDismiss = { showInterval = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showExportPicker) {
|
|
||||||
ExportCalendarPicker(
|
|
||||||
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
|
||||||
onExport = onExportBackup,
|
|
||||||
onDismiss = { showExportPicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
|
||||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
|
||||||
* the picked file with the chosen calendar ids.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ExportCalendarPicker(
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// Seed once with everything selected and hold it across recomposition and
|
|
||||||
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
|
||||||
// it would silently reset the user's de-selections whenever the provider
|
|
||||||
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
|
||||||
// — the data layer intersects the chosen set with the eligible calendars.
|
|
||||||
var selected by rememberSaveable(
|
|
||||||
stateSaver = listSaver(
|
|
||||||
save = { it.toList() },
|
|
||||||
restore = { it.toSet() },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
mutableStateOf(calendars.map { it.id }.toSet())
|
|
||||||
}
|
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
onExport(uri, selected)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FullScreenPicker(
|
|
||||||
title = stringResource(R.string.calendars_export_title),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
) {
|
|
||||||
HintText(stringResource(R.string.calendars_export_hint))
|
|
||||||
calendars.forEachIndexed { index, calendar ->
|
|
||||||
val isSelected = calendar.id in selected
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
summary = calendar.description,
|
|
||||||
position = positionOf(index, calendars.size),
|
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
|
||||||
trailing = {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
|
||||||
},
|
|
||||||
enabled = selected.isNotEmpty(),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.calendars_export_action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -560,6 +354,7 @@ private fun CalendarEditor(
|
|||||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
deleteLocked: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
@@ -593,11 +388,20 @@ private fun CalendarEditor(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
IconButton(onClick = { confirmDelete = true }) {
|
// Disabled rather than hidden while the special-dates
|
||||||
|
// sync owns this calendar; the card below says why.
|
||||||
|
IconButton(
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
enabled = !deleteLocked,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = if (deleteLocked) {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,7 +430,20 @@ private fun CalendarEditor(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
if (deleteLocked) {
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.Default.Info,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
@@ -698,10 +515,28 @@ private fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-row enable/disable control. Checked = the calendar is shown in the
|
* The row's supporting line: the states that make this calendar behave unlike a
|
||||||
* app; unchecking disables it (events, filters and pickers all drop it) without
|
* plain writable one (#76), then its own description.
|
||||||
* touching any provider data. Carries its own content description so the toggle
|
*/
|
||||||
* is self-describing to screen readers even on a dimmed row.
|
@Composable
|
||||||
|
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||||
|
val states = calendar.stateLabels().map { label ->
|
||||||
|
stringResource(
|
||||||
|
when (label) {
|
||||||
|
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||||
|
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||||
|
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||||
|
return parts.joinToString(" · ").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-row on/off control, writing the system's device-local
|
||||||
|
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
|
||||||
|
* stops its reminders. Carries its own content description.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun EnableSwitch(
|
private fun EnableSwitch(
|
||||||
@@ -709,7 +544,7 @@ private fun EnableSwitch(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onToggle: (Boolean) -> Unit,
|
onToggle: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||||
Switch(
|
Switch(
|
||||||
checked = enabled,
|
checked = enabled,
|
||||||
onCheckedChange = onToggle,
|
onCheckedChange = onToggle,
|
||||||
@@ -883,113 +718,31 @@ private fun CalendarGroupMenu(
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
internal fun SectionHeader(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
// The cards' own edge, so header and group share a left margin.
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HintText(text: String) {
|
internal fun HintText(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
|
||||||
@Composable
|
|
||||||
private fun rememberFolderName(uriString: String?): String? {
|
|
||||||
val context = LocalContext.current
|
|
||||||
return remember(uriString) {
|
|
||||||
uriString?.let {
|
|
||||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
|
||||||
@Composable
|
|
||||||
private fun backupIntervalLabel(minutes: Long): String {
|
|
||||||
val duration = when {
|
|
||||||
minutes % MINUTES_PER_WEEK == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
|
||||||
minutes % MINUTES_PER_DAY == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
|
||||||
minutes % 60L == 0L ->
|
|
||||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
|
||||||
else ->
|
|
||||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
|
||||||
}
|
|
||||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
|
||||||
@Composable
|
|
||||||
private fun backupStatusText(status: BackupStatus): String {
|
|
||||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
|
||||||
val relative = DateUtils.getRelativeTimeSpanString(
|
|
||||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
|
||||||
).toString()
|
|
||||||
return if (status.lastSuccess) {
|
|
||||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
|
||||||
@Composable
|
|
||||||
private fun BackupIntervalDialog(
|
|
||||||
currentMinutes: Long,
|
|
||||||
onConfirm: (Long) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
|
||||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
|
||||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
|
||||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
|
||||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
|
||||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
|
||||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
|
||||||
onDismiss()
|
|
||||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MINUTES_PER_DAY = 1_440L
|
|
||||||
private const val MINUTES_PER_WEEK = 10_080L
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the app to open for managing a synced calendar's account. The account's
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,10 +42,7 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val suppressedStore: SuppressedReminderStore,
|
|
||||||
private val notifier: ReminderNotifier,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -62,20 +56,6 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Calendars the user has disabled in the app. This screen is the only
|
|
||||||
* surface that lists them, so it both reads the set (to dim the rows) and
|
|
||||||
* toggles it. Every other surface simply excludes these ids.
|
|
||||||
*/
|
|
||||||
val disabledCalendarIds: StateFlow<Set<Long>> =
|
|
||||||
prefs.disabledCalendarIds
|
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
@@ -92,6 +72,25 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = AutoBackupUiState(),
|
initialValue = AutoBackupUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managed special-dates calendars whose deletion would not stick: while the
|
||||||
|
* feature is on, `SpecialDatesSyncEngine.reconcileCalendars` undoes it on
|
||||||
|
* the next pass. Read off each calendar's durable [CalendarSource.isManaged]
|
||||||
|
* marker rather than the stored ids, which lag a sync pass behind.
|
||||||
|
*/
|
||||||
|
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
|
||||||
|
calendars,
|
||||||
|
settingsPrefs.specialDatesEnabled,
|
||||||
|
) { sources, enabled ->
|
||||||
|
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -140,54 +139,21 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
* Switch a calendar on or off — the app's one visibility model, writing the
|
||||||
* surface but Settings → Calendars (and hides its events) without touching
|
* system's `Calendars.VISIBLE`. A reminder that came due while the calendar
|
||||||
* provider data — purely a reversible Calendula-local view choice.
|
* was off stays gone when it is switched back on: the watermark has already
|
||||||
|
* moved past it (#75).
|
||||||
*/
|
*/
|
||||||
fun setDisabled(id: Long, disabled: Boolean) {
|
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||||
viewModelScope.launch {
|
repository.setCalendarsVisible(listOf(id), visible)
|
||||||
val current = prefs.disabledCalendarIds.first()
|
|
||||||
val next = if (disabled) current + id else current - id
|
|
||||||
if (next != current) {
|
|
||||||
prefs.setDisabledCalendarIds(next)
|
|
||||||
if (!disabled) recoverReminders(setOf(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable every calendar of one account in a single write — the
|
* Switch every calendar of one account on or off. Each row is written on its
|
||||||
* "toggle all" affordance on an account header. Done as one set update so the
|
* own, in one coroutine so the writes can't race.
|
||||||
* per-calendar [setDisabled] calls can't race each other.
|
|
||||||
*/
|
*/
|
||||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||||
viewModelScope.launch {
|
repository.setCalendarsVisible(ids, visible)
|
||||||
val current = prefs.disabledCalendarIds.first()
|
|
||||||
val next = if (disabled) current + ids else current - ids.toSet()
|
|
||||||
if (next != current) {
|
|
||||||
prefs.setDisabledCalendarIds(next)
|
|
||||||
if (!disabled) recoverReminders(current intersect ids.toSet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
|
||||||
* still relevant (event not yet over), then drop them from the stash. Runs
|
|
||||||
* after the disabled set is written, so the notifier's own disabled gate lets
|
|
||||||
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
|
||||||
* (reminders on + postable), and there is no later re-scan, so alerts left
|
|
||||||
* unposted because those gates are closed are simply released.
|
|
||||||
*/
|
|
||||||
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
|
||||||
if (reEnabledIds.isEmpty()) return
|
|
||||||
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
|
||||||
if (recovered.isNotEmpty() &&
|
|
||||||
settingsPrefs.remindersEnabled.first() &&
|
|
||||||
notifier.canPost()
|
|
||||||
) {
|
|
||||||
recovered.forEach { notifier.post(it) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Automatic backup (issue #8) ------------------------------------
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One account's calendars, as every surface that lists calendars by account
|
||||||
|
* shows them. An account is identified by name **and** type (#77): a Google and
|
||||||
|
* a DAVx5 account can share an address and still be two separate accounts.
|
||||||
|
*/
|
||||||
|
data class CalendarAccountGroup(
|
||||||
|
/** Stable identity: what makes two calendars belong to the same account. */
|
||||||
|
val key: AccountKey,
|
||||||
|
/** The account's own name, as shown when it is unambiguous. */
|
||||||
|
val label: String,
|
||||||
|
/** True when another group shows the same [label] under a different type. */
|
||||||
|
val ambiguous: Boolean,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
) {
|
||||||
|
val accountType: String get() = key.type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pair a group is keyed on. */
|
||||||
|
data class AccountKey(val name: String, val type: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group [calendars] under their owning account, preserving the provider's order
|
||||||
|
* within each group and ordering groups by first appearance.
|
||||||
|
*
|
||||||
|
* The label falls back through name → type → the first calendar's own name, so
|
||||||
|
* a calendar with no account still lands somewhere sensible.
|
||||||
|
*/
|
||||||
|
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||||
|
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
|
||||||
|
val labels = grouped.mapValues { (key, cals) ->
|
||||||
|
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
|
||||||
|
}
|
||||||
|
val shared = labels.values.groupingBy { it }.eachCount()
|
||||||
|
return grouped.map { (key, cals) ->
|
||||||
|
val label = labels.getValue(key)
|
||||||
|
CalendarAccountGroup(
|
||||||
|
key = key,
|
||||||
|
label = label,
|
||||||
|
ambiguous = shared.getValue(label) > 1,
|
||||||
|
calendars = cals,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to write above a group: its account name, qualified with the app the
|
||||||
|
* account comes from when another account shares the name (#77).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||||
|
if (!group.ambiguous) {
|
||||||
|
group.label
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The human name of the app backing [accountType], falling back to the raw
|
||||||
|
* account type when no installed app resolves for it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun sourceAppName(accountType: String): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(accountType) {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val packages = sourceAppPackages(context, accountType)
|
||||||
|
packages.firstNotNullOfOrNull { pkg ->
|
||||||
|
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||||
|
} ?: accountType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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 = eventFill(color, dark, soften),
|
tint = eventAccent(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -19,6 +20,7 @@ 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
|
||||||
@@ -27,19 +29,23 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +56,8 @@ 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 host screen owns the drawer state; the sheet reads it only to dismiss
|
||||||
|
* 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.
|
||||||
@@ -59,12 +66,17 @@ 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
|
||||||
@@ -83,7 +95,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 = { Icon(view.icon, contentDescription = null) },
|
leading = { DrawerLeadingIcon(view.icon) },
|
||||||
onClick = { onSelectView(view) },
|
onClick = { onSelectView(view) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,7 +105,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 = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||||
onClick = { showDatePicker = true },
|
onClick = { showDatePicker = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,7 +119,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 = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
||||||
onClick = onSettings,
|
onClick = onSettings,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -126,13 +138,27 @@ 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(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 16.dp,
|
||||||
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -164,6 +190,11 @@ 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(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -42,18 +44,21 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
|
|||||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||||
|
*
|
||||||
|
* The list holds event *targets* only, so a switched-off, read-only or managed
|
||||||
|
* calendar is absent (#76). [onManageCalendars], when given, adds the footer row
|
||||||
|
* naming the possible reasons and opening the calendar manager.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ColumnScope.CalendarPickerGroups(
|
fun ColumnScope.CalendarPickerGroups(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
val syncedGroups = remember(calendars) {
|
val syncedGroups = remember(calendars) {
|
||||||
calendars.filterNot { it.isLocal }
|
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.isNotEmpty()) {
|
if (local.isNotEmpty()) {
|
||||||
@@ -65,16 +70,33 @@ fun ColumnScope.CalendarPickerGroups(
|
|||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
syncedGroups.forEachIndexed { index, (account, cals) ->
|
syncedGroups.forEachIndexed { index, group ->
|
||||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||||
CalendarPickerGroup(
|
CalendarPickerGroup(
|
||||||
title = account,
|
title = accountGroupTitle(group),
|
||||||
leading = { SourceLogo(cals.first().accountType) },
|
leading = { SourceLogo(group.accountType) },
|
||||||
calendars = cals,
|
calendars = group.calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onManageCalendars != null) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendar_picker_missing_title),
|
||||||
|
summary = stringResource(R.string.calendar_picker_missing_summary),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onManageCalendars,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||||
@@ -155,20 +177,22 @@ fun LeadingAvatar(icon: ImageVector) {
|
|||||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
val candidates = buildList {
|
for (pkg in sourceAppPackages(context, accountType)) {
|
||||||
curatedSourcePackage(accountType)?.let { add(it) }
|
|
||||||
AccountManager.get(context).authenticatorTypes
|
|
||||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
|
||||||
?.packageName
|
|
||||||
?.let { add(it) }
|
|
||||||
}
|
|
||||||
for (pkg in candidates) {
|
|
||||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||||
if (bitmap != null) return bitmap.asImageBitmap()
|
if (bitmap != null) return bitmap.asImageBitmap()
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apps that could stand for [accountType], best candidate first. */
|
||||||
|
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = buildList {
|
||||||
|
curatedSourcePackage(accountType)?.let { add(it) }
|
||||||
|
AccountManager.get(context).authenticatorTypes
|
||||||
|
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||||
|
?.packageName
|
||||||
|
?.let { add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,45 +2,53 @@ 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 androidx.compose.ui.graphics.luminance
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
|
||||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
* per role — before display (issue #36). Provided app-wide from the setting; the
|
||||||
* setting; the default `true` keeps the historical look. When off, the raw
|
* default `true` is the app's own look. When off, the raw provider colour is
|
||||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
|
||||||
* and other calendar apps. Widgets live outside this composition and read the
|
* apps. Widgets live outside this composition and read the preference directly,
|
||||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
* then pass the flag through.
|
||||||
*/
|
*/
|
||||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
* Fill for a surface that carries text on it — a week/day block, a month bar, a
|
||||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
* widget row, a picker swatch. Pair it with [eventInk].
|
||||||
* pastelize also returns an opaque colour).
|
|
||||||
*/
|
*/
|
||||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
Color(eventTone(rawArgb, dark, soften).container)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
|
||||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
|
||||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
* pinned to contrast with the surface instead of with ink.
|
||||||
* 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 eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
Color(eventTone(rawArgb, dark, soften).accent)
|
||||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
|
||||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
*
|
||||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
* Takes the fill rather than the raw colour so it stays correct for the raw
|
||||||
|
* (un-harmonised) path too, where the provider decides the lightness.
|
||||||
*/
|
*/
|
||||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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>,
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,16 +23,26 @@ import androidx.compose.ui.unit.dp
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
@@ -48,6 +58,10 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
* back.
|
* back.
|
||||||
|
*
|
||||||
|
* [leadTimeSummary] adds a second line to each lead-time row, for the all-day
|
||||||
|
* pickers: the hour comes from the separate "show all-day reminders at" setting,
|
||||||
|
* so the lead time alone doesn't say when anything happens.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -57,6 +71,7 @@ fun ReminderDefaultPicker(
|
|||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (ReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
leadTimeSummary: (@Composable (Int) -> String?)? = null,
|
||||||
) {
|
) {
|
||||||
// Optimistic local state: once the user edits, the chosen override is
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
// authoritative while the picker is open, so quick successive toggles compose
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
@@ -132,6 +147,7 @@ fun ReminderDefaultPicker(
|
|||||||
val checked = minute in selectedMinutes
|
val checked = minute in selectedMinutes
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderLeadTimeLabel(minute),
|
title = reminderLeadTimeLabel(minute),
|
||||||
|
summary = leadTimeSummary?.invoke(minute),
|
||||||
position = positionOf(index, rowCount),
|
position = positionOf(index, rowCount),
|
||||||
selected = checked,
|
selected = checked,
|
||||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||||
@@ -196,14 +212,15 @@ private fun CustomReminderEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
||||||
|
* on the same left margin as the rows themselves. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,12 +230,17 @@ internal fun PickerDescription(text: String) {
|
|||||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||||
|
*
|
||||||
|
* Every option carries the dates it resolves to today as its summary, computed
|
||||||
|
* through the same [dayCount] the agenda windows by (hence [weekStart]) — only
|
||||||
|
* the concrete span tells "This week" and "Next 7 days" apart.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaRangePicker(
|
fun AgendaRangePicker(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
selected: AgendaRange,
|
selected: AgendaRange,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
onSelect: (AgendaRange) -> Unit,
|
onSelect: (AgendaRange) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -232,10 +254,21 @@ fun AgendaRangePicker(
|
|||||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val locale = currentLocale()
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
// The agenda's own end-day arithmetic (anchor + dayCount - 1), so the dates
|
||||||
|
// match what it will show.
|
||||||
|
val windowSummary: (AgendaRange) -> String = { range ->
|
||||||
|
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||||
|
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
|
||||||
|
}
|
||||||
|
|
||||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = agendaRangeLabel(option),
|
title = agendaRangeLabel(option),
|
||||||
|
summary = windowSummary(option),
|
||||||
position = position,
|
position = position,
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
@@ -270,6 +303,9 @@ fun AgendaRangePicker(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.agenda_range_custom)
|
stringResource(R.string.agenda_range_custom)
|
||||||
},
|
},
|
||||||
|
// An unset custom window has no day count yet, so the row stays
|
||||||
|
// single-line until it does.
|
||||||
|
summary = if (customSelected) windowSummary(selected) else null,
|
||||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||||
selected = customSelected,
|
selected = customSelected,
|
||||||
trailing = if (customSelected) {
|
trailing = if (customSelected) {
|
||||||
@@ -318,35 +354,66 @@ private fun CustomDaysEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
|
||||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
|
||||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
|
||||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
|
||||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
* confirming a custom value applies via [onSelect] and closes. [label] renders a
|
||||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
|
||||||
* is reused for both the rows and the custom preview.
|
* 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 SnoozeDurationPicker(
|
fun DurationPicker(
|
||||||
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 customSelected = selected !in presets
|
val inherits = selected == null
|
||||||
|
// 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(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
|
||||||
}
|
}
|
||||||
var unit by rememberSaveable {
|
var unit by rememberSaveable {
|
||||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: 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(
|
||||||
@@ -367,10 +434,10 @@ fun SnoozeDurationPicker(
|
|||||||
// 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 = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
title = custom?.let { label(it) } ?: 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 = customSelected,
|
selected = custom != null,
|
||||||
trailing = if (customSelected) {
|
trailing = if (custom != null) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -382,12 +449,13 @@ fun SnoozeDurationPicker(
|
|||||||
enter = expandEnter(),
|
enter = expandEnter(),
|
||||||
exit = collapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomSnoozeEditor(
|
CustomDurationEditor(
|
||||||
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()
|
||||||
@@ -397,41 +465,48 @@ fun SnoozeDurationPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole hours if the delay divides evenly, else minutes. */
|
/** Whole hours if the length divides evenly, else minutes. */
|
||||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
private fun durationCustomUnit(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 snoozeCustomAmount(minutes: Int): Int =
|
private fun durationCustomAmount(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" snooze editor: a tonal card connected to the Custom row
|
* The expanded "Custom" duration 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 delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
* the length it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
* amount. [onConfirm] receives the final delay in minutes.
|
* amount that stays within [maxMinutes]. Over the cap, the preview names the
|
||||||
|
* limit instead of a length the setting could not keep. [onConfirm] receives the
|
||||||
|
* final length in minutes.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomSnoozeEditor(
|
private fun CustomDurationEditor(
|
||||||
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 amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
|
||||||
|
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 = amount?.let { label(it * unit.minutesFactor) }
|
preview = when {
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
amount != null -> label(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(it * unit.minutesFactor) } },
|
onConfirm = { amount?.let(onConfirm) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import androidx.compose.ui.text.SpanStyle
|
|||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
||||||
|
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -98,6 +103,33 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
|
||||||
|
* the recurrence picker, where [recurrenceText]'s phrase says what the rule is
|
||||||
|
* and this says what it does. Expands via [upcomingOccurrences] and inherits its
|
||||||
|
* limits. A rule that yields nothing says so rather than showing an empty list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun nextOccurrencesText(
|
||||||
|
rule: SimpleRecurrence,
|
||||||
|
start: kotlinx.datetime.LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
||||||
|
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
||||||
|
// Years only once they carry information (see [spansMultipleYears]).
|
||||||
|
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
|
||||||
|
val formatter = localizedDateFormatter(locale, pattern)
|
||||||
|
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
|
||||||
|
return stringResource(
|
||||||
|
R.string.event_edit_recurrence_next,
|
||||||
|
ListFormatter.getInstance(locale).format(formatted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
|
||||||
|
private const val NEXT_OCCURRENCE_COUNT = 3
|
||||||
|
|
||||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
val dow = when (token.takeLast(2).uppercase()) {
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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,3 +37,21 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
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,6 +17,7 @@ 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 =
|
||||||
@@ -42,6 +43,18 @@ 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".
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -240,7 +241,12 @@ private fun SectionHeader(text: String) {
|
|||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
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 {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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
|
||||||
@@ -42,6 +42,7 @@ 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
|
||||||
@@ -53,14 +54,19 @@ 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.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
|
||||||
@@ -68,12 +74,27 @@ 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.eventMoveAction
|
||||||
|
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
|
||||||
@@ -87,8 +108,14 @@ 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.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
|
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.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -100,13 +127,6 @@ 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
|
||||||
|
|
||||||
@@ -195,6 +215,7 @@ 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)
|
||||||
@@ -221,6 +242,7 @@ fun DayScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(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,
|
||||||
@@ -264,7 +286,6 @@ 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()
|
||||||
@@ -275,11 +296,9 @@ 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 }
|
||||||
val maxV = scrollState.maxValue
|
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||||
val target = with(density) {
|
// centring the range centres midday at whatever hour height is in force.
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}.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
|
||||||
@@ -295,31 +314,54 @@ 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)
|
||||||
|
|
||||||
AnimatedContent(
|
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||||
targetState = state,
|
// floating block inside the outgoing page.
|
||||||
modifier = modifier.then(swipeModifier),
|
val dragController = rememberTimelineDragController()
|
||||||
contentKey = { s ->
|
val move = LocalEventMove.current
|
||||||
|
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) {
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
DayUiState.Loading -> DayLoading()
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
DayUiState.Loading -> "loading"
|
is DayUiState.Success -> DaySuccess(
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,8 +371,10 @@ private fun DaySuccess(
|
|||||||
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,
|
||||||
@@ -349,8 +393,10 @@ private fun DaySuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +410,7 @@ private fun DayTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
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,
|
||||||
@@ -371,9 +418,10 @@ private fun DayTopBar(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = formatDayTitle(date, locale, currentYear),
|
title = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = date,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -482,66 +530,73 @@ 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 use24Hour = LocalUse24HourFormat.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val locale = currentLocale()
|
val density = LocalDensity.current
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||||
|
// 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()) {
|
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||||
// 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.
|
||||||
Column(
|
HourGutter(
|
||||||
modifier = Modifier
|
scrollState = scrollState,
|
||||||
.width(GUTTER_WIDTH)
|
hourHeight = hourHeight,
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
dragController = dragController,
|
||||||
.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
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,13 +609,20 @@ 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) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.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)
|
||||||
|
}
|
||||||
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.
|
||||||
@@ -587,25 +649,43 @@ private fun DayColumnCard(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
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 laneWidth = colWidth / block.laneCount
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
key(block.event.eventId, ordinal) {
|
||||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
val laneWidth = colWidth / block.laneCount
|
||||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
EventBlock(
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
block = block,
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
dark = dark,
|
val place = animatedBlockPlacement(
|
||||||
onClick = { onEventClick(block.event) },
|
x = laneWidth * block.lane,
|
||||||
modifier = Modifier
|
y = top,
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
width = laneWidth,
|
||||||
.width(laneWidth)
|
height = height,
|
||||||
.height(height)
|
)
|
||||||
.padding(horizontal = 1.dp),
|
EventBlock(
|
||||||
)
|
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 = HOUR_HEIGHT)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,7 +695,11 @@ 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) }
|
||||||
@@ -623,31 +707,66 @@ 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 showTime = block.endMin - block.startMin >= 45
|
val density = LocalDensity.current
|
||||||
|
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 = moveAction != null && 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 { contentDescription = "$title, $timeLabel" },
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
if (showTitle) {
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = if (showTime) 1 else 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
|
||||||
)
|
|
||||||
if (showTime) {
|
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = 1,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.6f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTime) {
|
||||||
|
BlockTimeLabel(
|
||||||
|
label = timeLabel,
|
||||||
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,17 +775,22 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayLoading() {
|
private fun DayLoading() {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||||
Box(
|
// doesn't resize the moment the real day arrives.
|
||||||
modifier = Modifier
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
.weight(1f)
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
.height(totalHeight)
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
.padding(horizontal = 2.dp)
|
Box(
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
modifier = Modifier
|
||||||
)
|
.weight(1f)
|
||||||
|
.height(totalHeight)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ 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
|
||||||
@@ -386,7 +387,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 = eventFill(instance.color, dark, LocalSoftenColors.current)
|
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -125,12 +127,14 @@ 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
|
||||||
@@ -155,6 +159,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -193,6 +198,7 @@ fun EventEditScreen(
|
|||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
@@ -309,6 +315,7 @@ fun EventEditScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,30 +453,11 @@ private fun SaveScopeDialog(
|
|||||||
onSelect: (RecurringWriteScope) -> Unit,
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
RecurringScopeDialog(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_recurring_title),
|
||||||
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
onSelect = onSelect,
|
||||||
text = {
|
onDismiss = onDismiss,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
allowOccurrence = !recurrenceChanged,
|
||||||
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)) }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +488,7 @@ private fun EventEditContent(
|
|||||||
state: EventEditUiState,
|
state: EventEditUiState,
|
||||||
viewModel: EventEditViewModel,
|
viewModel: EventEditViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
@@ -509,6 +498,10 @@ private fun EventEditContent(
|
|||||||
// they're locked here; everything else (reminders, location, notes) is the
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
// user's to edit.
|
// user's to edit.
|
||||||
val locked = state.isManaged
|
val locked = state.isManaged
|
||||||
|
// Read in the form's own window, not the picker's: the focused field lives
|
||||||
|
// here, so this is the controller that can put its keyboard away.
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -553,7 +546,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 { eventFill(it.color, dark, soften) }
|
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
|
||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
@@ -1114,6 +1107,15 @@ private fun EventEditContent(
|
|||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A full-screen picker is a change of place, so the keyboard shouldn't
|
||||||
|
// follow it. The field keeps its text; only focus and the IME go.
|
||||||
|
LaunchedEffect(showCalendarPicker) {
|
||||||
|
if (showCalendarPicker) {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCalendarPicker) {
|
if (showCalendarPicker) {
|
||||||
CalendarPicker(
|
CalendarPicker(
|
||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
@@ -1122,6 +1124,15 @@ private fun EventEditContent(
|
|||||||
viewModel.setCalendar(it)
|
viewModel.setCalendar(it)
|
||||||
showCalendarPicker = false
|
showCalendarPicker = false
|
||||||
},
|
},
|
||||||
|
// Close the picker first: it is a Compose Dialog in its own window,
|
||||||
|
// always above the activity's content, so the manager would
|
||||||
|
// otherwise open behind it and the tap would look dead.
|
||||||
|
onManageCalendars = onManageCalendars?.let { openManager ->
|
||||||
|
{
|
||||||
|
showCalendarPicker = false
|
||||||
|
openManager()
|
||||||
|
}
|
||||||
|
},
|
||||||
onDismiss = { showCalendarPicker = false },
|
onDismiss = { showCalendarPicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1141,7 +1152,7 @@ private fun EventEditContent(
|
|||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
startDay = form.start.date.dayOfWeek,
|
startDate = form.start.date,
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
onSelect = { rrule ->
|
onSelect = { rrule ->
|
||||||
viewModel.setRecurrence(rrule)
|
viewModel.setRecurrence(rrule)
|
||||||
@@ -1309,15 +1320,20 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
|||||||
* and an end condition — only that step needs an OK button. A rule the
|
* and an end condition — only that step needs an OK button. A rule the
|
||||||
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||||
* user picks something here.
|
* user picks something here.
|
||||||
|
*
|
||||||
|
* Both steps show the rule as *dates* as well as words: every preset row
|
||||||
|
* carries the next few occurrences it would produce from [startDate], and the
|
||||||
|
* custom step repeats that under its live read-out.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecurrencePickerDialog(
|
private fun RecurrencePickerDialog(
|
||||||
current: String?,
|
current: String?,
|
||||||
startDay: DayOfWeek,
|
startDate: LocalDate,
|
||||||
firstDayOfWeek: DayOfWeek,
|
firstDayOfWeek: DayOfWeek,
|
||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val startDay = startDate.dayOfWeek
|
||||||
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||||
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||||
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||||
@@ -1369,16 +1385,19 @@ private fun RecurrencePickerDialog(
|
|||||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||||
}
|
}
|
||||||
val customResult: String? = if (interval != null && customEnd != null) {
|
// Kept as the rule object, not just its RRULE text: the read-out and the
|
||||||
|
// date list must describe the one thing OK would save.
|
||||||
|
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
|
||||||
SimpleRecurrence(
|
SimpleRecurrence(
|
||||||
freq = freq,
|
freq = freq,
|
||||||
interval = interval,
|
interval = interval,
|
||||||
end = customEnd,
|
end = customEnd,
|
||||||
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||||
).toRRule()
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
val customResult: String? = customRule?.toRRule()
|
||||||
|
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_recurrence),
|
title = stringResource(R.string.event_detail_recurrence),
|
||||||
@@ -1409,6 +1428,7 @@ private fun RecurrencePickerDialog(
|
|||||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(recurrencePresetLabel(entry)),
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
|
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
|
||||||
position = positionOf(index + 1, rowCount),
|
position = positionOf(index + 1, rowCount),
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||||
@@ -1460,6 +1480,17 @@ private fun RecurrencePickerDialog(
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Rendered even while incomplete (as an empty line), so the
|
||||||
|
// controls below don't shift as it comes and goes.
|
||||||
|
Text(
|
||||||
|
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
// How often: an interval amount plus a frequency segmented row —
|
// How often: an interval amount plus a frequency segmented row —
|
||||||
// all four units on show, no unit hidden behind a dropdown.
|
// all four units on show, no unit hidden behind a dropdown.
|
||||||
GroupedSurface(
|
GroupedSurface(
|
||||||
@@ -1976,6 +2007,9 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
|||||||
/**
|
/**
|
||||||
* Visibility selector: one card per level, each with its own icon; the
|
* Visibility selector: one card per level, each with its own icon; the
|
||||||
* current level is highlighted. Tap picks and closes.
|
* current level is highlighted. Tap picks and closes.
|
||||||
|
*
|
||||||
|
* Every level carries a line saying who this affects — the labels alone don't
|
||||||
|
* say that visibility is about what others on a shared calendar see.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun VisibilityPickerDialog(
|
private fun VisibilityPickerDialog(
|
||||||
@@ -1988,6 +2022,7 @@ private fun VisibilityPickerDialog(
|
|||||||
options = AccessLevel.entries.toList(),
|
options = AccessLevel.entries.toList(),
|
||||||
selected = selected,
|
selected = selected,
|
||||||
label = { stringResource(accessLevelLabel(it)) },
|
label = { stringResource(accessLevelLabel(it)) },
|
||||||
|
summary = { stringResource(accessLevelSummary(it)) },
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||||
@@ -2080,6 +2115,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
|||||||
AccessLevel.Confidential -> R.string.event_access_confidential
|
AccessLevel.Confidential -> R.string.event_access_confidential
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What each level means for people the calendar is shared with. */
|
||||||
|
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
||||||
|
AccessLevel.Default -> R.string.event_access_default_summary
|
||||||
|
AccessLevel.Public -> R.string.event_access_public_summary
|
||||||
|
AccessLevel.Private -> R.string.event_access_private_summary
|
||||||
|
AccessLevel.Confidential -> R.string.event_access_confidential_summary
|
||||||
|
}
|
||||||
|
|
||||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
@@ -2277,6 +2320,7 @@ private fun CalendarPicker(
|
|||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_calendar),
|
title = stringResource(R.string.event_detail_calendar),
|
||||||
@@ -2286,6 +2330,7 @@ private fun CalendarPicker(
|
|||||||
calendars = calendars,
|
calendars = calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -19,6 +20,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
@@ -50,28 +52,38 @@ 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.hours
|
import kotlin.time.Duration.Companion.minutes
|
||||||
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 two
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* sources want different reminder handling (#49).
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
|
* event's length ([ownsDuration], #54) — one that does is never restretched to the
|
||||||
|
* default-duration setting.
|
||||||
*/
|
*/
|
||||||
enum class ImportSource {
|
enum class ImportSource(internal val ownsDuration: Boolean) {
|
||||||
/**
|
/**
|
||||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
* An external `ACTION_INSERT` intent that named both ends (another app/widget,
|
||||||
* It carries no reminders of its own, so the settings default is applied
|
* e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
|
||||||
* automatically, exactly like an in-app new event.
|
* own, so the settings default is applied automatically, exactly like an
|
||||||
|
* in-app new event; its span, though, is the caller's.
|
||||||
*/
|
*/
|
||||||
Insert,
|
Insert(ownsDuration = true),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* rather than silently applied or suppressed. Its span is the file's.
|
||||||
*/
|
*/
|
||||||
File,
|
File(ownsDuration = true),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +124,9 @@ 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)
|
||||||
@@ -177,18 +192,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
repository.calendars().catch { emit(emptyList()) }
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
||||||
* excluded, so you can't create into a calendar you've removed from the app;
|
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
||||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
* A last-used preselect landing on an excluded calendar falls back to the
|
||||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
||||||
* Managed special-dates calendars are excluded too: their events are owned by
|
*
|
||||||
* the contact sync, which would delete any user event created there.
|
* This is the list of *targets*. An event already living in an excluded
|
||||||
|
* calendar keeps it — [state] adds it back to the picker.
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
||||||
allCalendars,
|
calendars.filter { it.isEventTarget }
|
||||||
prefs.disabledCalendarIds,
|
|
||||||
) { calendars, disabled ->
|
|
||||||
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
@@ -234,11 +247,14 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// off the calendar's durable marker, not a stored id, so it holds after a
|
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||||
// backup restore too.
|
// backup restore too.
|
||||||
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||||
// The picker offers writable calendars only; when editing a managed event
|
// The event's own calendar is added back whenever it isn't a target
|
||||||
// its own (excluded) calendar is added back so the row still names it.
|
// (managed, or switched off), so the row keeps naming it and saving can
|
||||||
|
// leave the event where it is. A read-only one is still no target.
|
||||||
|
val ownCalendar = resolvedCalendar?.takeIf { own ->
|
||||||
|
own.canModifyContents && external.writable.none { it.id == own.id }
|
||||||
|
}
|
||||||
val pickerCalendars =
|
val pickerCalendars =
|
||||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
if (ownCalendar != null) external.writable + ownCalendar else external.writable
|
||||||
else external.writable
|
|
||||||
// An all-day event is date-anchored, so a zone is meaningless on it —
|
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||||
// the field is withheld from both lists rather than shown as a no-op.
|
// the field is withheld from both lists rather than shown as a no-op.
|
||||||
val offerableFields = EventFormField.entries.toSet() -
|
val offerableFields = EventFormField.entries.toSet() -
|
||||||
@@ -280,8 +296,10 @@ 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). No-op when a form is already open, so user input
|
* or 09:00 (any other day). The event opens an hour long and is stretched to
|
||||||
* survives configuration changes; [reset] clears it when the screen closes.
|
* the configured default by [applyDefaultDuration], like the reminders.
|
||||||
|
* 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
|
||||||
@@ -298,9 +316,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
||||||
|
.toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
|
applyDefaultDuration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -319,6 +339,12 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -326,8 +352,9 @@ 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.Insert, ImportSource.InsertOpenEnded ->
|
||||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
else applyDefaultReminder()
|
else applyDefaultReminder()
|
||||||
|
|
||||||
@@ -356,7 +383,9 @@ 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)
|
||||||
_form.value = form.copy(reminders = reminders)
|
// Write through update(): the duration default resolves in parallel,
|
||||||
|
// 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()) {
|
||||||
@@ -375,6 +404,39 @@ 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.
|
||||||
@@ -453,6 +515,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +540,9 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -498,9 +564,10 @@ 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 unless
|
// A fresh event re-inherits the new calendar's default reminder and
|
||||||
// the user has already hand-edited it (guarded inside).
|
// length unless the user has already hand-edited them (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) }
|
||||||
@@ -557,8 +624,18 @@ 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)) }
|
|
||||||
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
// Setting an end by hand is the user picking a length: it freezes the
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||||
|
import de.jeanlucmakiola.floret.components.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
|
||||||
|
|
||||||
@@ -61,10 +63,23 @@ private fun FilterList(
|
|||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
Text(
|
Text(
|
||||||
text = group.account,
|
text = if (group.ambiguous) {
|
||||||
|
stringResource(
|
||||||
|
R.string.calendars_account_from_source,
|
||||||
|
group.account,
|
||||||
|
sourceAppName(group.accountType),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
group.account
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -94,7 +109,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
|||||||
repeat(4) {
|
repeat(4) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 28.dp)
|
.padding(horizontal = GroupedListInset)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.background(
|
.background(
|
||||||
@@ -120,6 +135,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 28.dp, vertical = 24.dp),
|
.padding(horizontal = GroupedListInset, vertical = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ sealed interface FilterUiState {
|
|||||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
/**
|
||||||
|
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
||||||
|
*
|
||||||
|
* [accountType] and [ambiguous] carry what the header needs to tell two
|
||||||
|
* same-named accounts from different apps apart (#77); the label itself is
|
||||||
|
* built in the UI layer, which is where the source app's name can be looked up.
|
||||||
|
*/
|
||||||
data class AccountGroup(
|
data class AccountGroup(
|
||||||
val account: String,
|
val account: String,
|
||||||
|
val accountType: String,
|
||||||
|
val ambiguous: Boolean,
|
||||||
val calendars: List<CalendarRow>,
|
val calendars: List<CalendarRow>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -30,16 +31,14 @@ class FilterViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
prefs.disabledCalendarIds,
|
) { calendars, hidden ->
|
||||||
) { calendars, hidden, disabled ->
|
// Calendars switched off device-wide don't belong in the drawer's
|
||||||
// Disabled calendars are gone from the app entirely — they don't
|
// hide/show list; they live only in Settings → Calendars.
|
||||||
// belong in the drawer's hide/show list (you can't hide what's
|
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||||
// already disabled). They live only in Settings → Calendars.
|
|
||||||
val enabled = calendars.filterNot { it.id in disabled }
|
|
||||||
if (enabled.isEmpty()) {
|
if (enabled.isEmpty()) {
|
||||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupByAccount(enabled, hidden))
|
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -61,30 +60,27 @@ class FilterViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group calendars under their owning account, preserving the provider's order
|
* Group calendars under their owning account — by name *and* type, so two
|
||||||
* within each group and ordering groups by first appearance. A calendar is
|
* accounts that merely share a name stay apart (#77) — preserving the
|
||||||
* "visible" when its id is *not* in [hidden].
|
* provider's order within each group and ordering groups by first appearance.
|
||||||
|
* A calendar is "visible" when its id is *not* in [hidden].
|
||||||
*/
|
*/
|
||||||
internal fun groupByAccount(
|
internal fun groupCalendarsForFilter(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
hidden: Set<Long>,
|
hidden: Set<Long>,
|
||||||
): List<AccountGroup> =
|
): List<AccountGroup> =
|
||||||
calendars
|
calendars.groupByAccount().map { group ->
|
||||||
.groupBy { it.accountLabel() }
|
AccountGroup(
|
||||||
.map { (account, cals) ->
|
account = group.label,
|
||||||
AccountGroup(
|
accountType = group.accountType,
|
||||||
account = account,
|
ambiguous = group.ambiguous,
|
||||||
calendars = cals.map { c ->
|
calendars = group.calendars.map { c ->
|
||||||
CalendarRow(
|
CalendarRow(
|
||||||
id = c.id,
|
id = c.id,
|
||||||
displayName = c.displayName,
|
displayName = c.displayName,
|
||||||
color = c.color,
|
color = c.color,
|
||||||
visible = c.id !in hidden,
|
visible = c.id !in hidden,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Account header text: the account name, falling back to its type. */
|
|
||||||
private fun CalendarSource.accountLabel(): String =
|
|
||||||
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fun ImportScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||||
// across imports — its one-shot `load` guard would then show the *previous*
|
// across imports — its one-shot `load` guard would then show the *previous*
|
||||||
@@ -155,7 +156,12 @@ fun ImportScreen(
|
|||||||
|
|
||||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
is ImportUiState.Many -> ManyContent(
|
||||||
|
state = s,
|
||||||
|
selected = selected,
|
||||||
|
onSelect = { selected = it },
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
|
)
|
||||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,10 +169,22 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
private fun ManyContent(
|
||||||
// No writable calendar to import into — tell the user honestly.
|
state: ImportUiState.Many,
|
||||||
|
selected: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
// No calendar to import into; carries the same way out the picker's footer
|
||||||
|
// offers below (#76).
|
||||||
if (state.calendars.isEmpty()) {
|
if (state.calendars.isEmpty()) {
|
||||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
CenteredMessage(
|
||||||
|
message = stringResource(R.string.import_no_calendar),
|
||||||
|
onClose = null,
|
||||||
|
actionLabel = stringResource(R.string.settings_manage_calendars)
|
||||||
|
.takeIf { onManageCalendars != null },
|
||||||
|
onAction = onManageCalendars,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +196,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
|
|||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
selectedId = selected,
|
selectedId = selected,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
if (state.warnings.isNotEmpty()) {
|
if (state.warnings.isNotEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -333,7 +352,12 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
private fun CenteredMessage(
|
||||||
|
message: String,
|
||||||
|
onClose: (() -> Unit)?,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onAction: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(24.dp),
|
Modifier.padding(24.dp),
|
||||||
@@ -344,6 +368,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
|||||||
if (onClose != null) {
|
if (onClose != null) {
|
||||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
}
|
}
|
||||||
|
if (actionLabel != null && onAction != null) {
|
||||||
|
Button(onClick = onAction) { Text(actionLabel) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,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.data.ics.IcsImporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
@@ -15,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -57,7 +57,6 @@ sealed interface ImportUiState {
|
|||||||
class ImportViewModel @Inject constructor(
|
class ImportViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val importer: IcsImporter,
|
private val importer: IcsImporter,
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -87,16 +86,12 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// A disabled calendar is removed from the app, so it can't be
|
// The same targets the event form offers ([isEventTarget]).
|
||||||
// an import target — exclude it alongside the read-only ones.
|
|
||||||
// Managed special-dates calendars are contact-derived and
|
|
||||||
// editor-locked, so they're not a valid destination either.
|
|
||||||
val disabled = prefs.disabledCalendarIds.first()
|
|
||||||
ImportUiState.Many(
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
.filter { it.isEventTarget },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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,7 +4,9 @@ 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
|
||||||
@@ -59,9 +61,11 @@ 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
|
||||||
@@ -77,7 +81,32 @@ 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.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
|
||||||
@@ -101,6 +130,7 @@ 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
|
||||||
@@ -110,6 +140,7 @@ 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
|
||||||
@@ -124,6 +155,7 @@ 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
|
||||||
@@ -137,6 +169,7 @@ import kotlinx.datetime.toJavaLocalDate
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -328,6 +361,7 @@ 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)
|
||||||
@@ -349,10 +383,12 @@ 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)) },
|
||||||
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,
|
||||||
@@ -364,23 +400,37 @@ fun MonthScreen(
|
|||||||
todayText = stringResource(R.string.month_today_action),
|
todayText = stringResource(R.string.month_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
// Anchor on today when its month is shown, else the 1st.
|
// Split has a selected day; the other styles anchor on
|
||||||
|
// today when its month is shown, else the 1st.
|
||||||
onCreateEvent(
|
onCreateEvent(
|
||||||
if (isOnCurrentMonth) today
|
when {
|
||||||
else LocalDate(titleMonth.year, titleMonth.month, 1),
|
viewStyle == MonthViewStyle.Split -> selectedDate
|
||||||
|
isOnCurrentMonth -> today
|
||||||
|
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||||
|
// 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(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(
|
||||||
|
LocalDimCutoff provides dimCutoff,
|
||||||
|
LocalMonthDrag provides chipDrag,
|
||||||
|
) {
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
ContinuousMonthContent(
|
ContinuousMonthContent(
|
||||||
state = continuousState,
|
state = continuousState,
|
||||||
@@ -417,10 +467,103 @@ 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,
|
||||||
@@ -504,19 +647,22 @@ 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,
|
||||||
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 = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = title,
|
title = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = titleDate,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -1277,7 +1423,7 @@ internal fun SplitMonthGrid(
|
|||||||
SplitDayCell(
|
SplitDayCell(
|
||||||
date = day,
|
date = day,
|
||||||
events = seated,
|
events = seated,
|
||||||
hidden = (week.countByDay[day] ?: 0) - seated.size,
|
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
||||||
isToday = day == state.today,
|
isToday = day == state.today,
|
||||||
// A page marks only the days its own month owns. Paging
|
// A page marks only the days its own month owns. Paging
|
||||||
// moves the selection before this month's replacement
|
// moves the selection before this month's replacement
|
||||||
@@ -1318,7 +1464,7 @@ private fun SplitDayCell(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||||
hidden: Int,
|
hidden: List<EventInstance>,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
inMonth: Boolean,
|
inMonth: Boolean,
|
||||||
@@ -1436,9 +1582,15 @@ private fun SplitDayCell(
|
|||||||
* bar with no dot to grow out of.
|
* bar with no dot to grow out of.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
private fun SplitDots(
|
||||||
|
date: LocalDate,
|
||||||
|
events: List<EventInstance>,
|
||||||
|
hidden: List<EventInstance>,
|
||||||
|
dark: Boolean,
|
||||||
|
) {
|
||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1461,18 +1613,21 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||||
|
.background(eventAccent(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden > 0) {
|
if (hidden.isNotEmpty()) {
|
||||||
// Tagged, not lifted: this count and the expanded grid's dot row are
|
// Tagged, not lifted: this count and the expanded grid's dot row are
|
||||||
// the same marker on the same day, so it travels with its cell like
|
// the same marker on the same day, so it travels with its cell like
|
||||||
// everything else rather than riding above the grid on its own layer.
|
// everything else rather than riding above the grid on its own layer.
|
||||||
Text(
|
Text(
|
||||||
text = "+$hidden",
|
text = "+${hidden.size}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
|
modifier = Modifier
|
||||||
|
.morphBounds(MonthMorphKey.Overflow(date))
|
||||||
|
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1698,6 +1853,50 @@ 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
|
||||||
@@ -1713,7 +1912,21 @@ 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
|
||||||
|
|
||||||
@@ -1778,7 +1991,13 @@ private fun MonthWeekRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
.onGloballyPositioned {
|
||||||
|
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 ->
|
||||||
@@ -1788,6 +2007,7 @@ 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,
|
||||||
@@ -1857,6 +2077,7 @@ 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,
|
||||||
@@ -1870,15 +2091,15 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
val hiddenColors = buildList {
|
val hiddenEvents = buildList {
|
||||||
week.spans
|
week.spans
|
||||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||||
.forEach { add(it.event.color) }
|
.forEach { add(it.event) }
|
||||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
addAll(timed.drop(pillsShown.size))
|
||||||
}.distinct().take(3)
|
}
|
||||||
OverflowDots(
|
OverflowDots(
|
||||||
colors = hiddenColors,
|
events = hiddenEvents,
|
||||||
extra = hidden - hiddenColors.size,
|
total = hidden,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
@@ -1937,6 +2158,73 @@ 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 != null && 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
|
||||||
@@ -2029,12 +2317,24 @@ 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,
|
||||||
@@ -2043,9 +2343,13 @@ 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 { contentDescription = title },
|
.semantics {
|
||||||
|
contentDescription = title
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -2058,37 +2362,51 @@ private fun MonthBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
|
/**
|
||||||
|
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest. A
|
||||||
|
* dot dims once every event sharing its colour has ended, the "+N" once the
|
||||||
|
* whole overflow has (#79).
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowDots(
|
private fun OverflowDots(
|
||||||
colors: List<Int>,
|
events: List<EventInstance>,
|
||||||
extra: Int,
|
total: Int,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val byColor = events.groupBy { it.color }
|
||||||
|
val dots = byColor.keys.take(3)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
colors.forEach { argb ->
|
dots.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.background(eventFill(argb, dark, soften), CircleShape),
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
|
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val extra = total - dots.size
|
||||||
if (extra > 0) {
|
if (extra > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "+$extra",
|
text = "+$extra",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when dimming is on and every one of [events] is already over. */
|
||||||
|
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
|
||||||
|
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthGridLoading() {
|
private fun MonthGridLoading() {
|
||||||
val shape = MaterialTheme.shapes.medium
|
val shape = MaterialTheme.shapes.medium
|
||||||
|
|||||||
@@ -67,6 +67,38 @@ 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
|
||||||
|
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||||
|
* a count because dimming is a per-event question (#79).
|
||||||
|
*/
|
||||||
|
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||||
|
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
|
return buildList {
|
||||||
|
spans.forEach { span ->
|
||||||
|
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||||
|
}
|
||||||
|
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class PermissionViewModel @Inject constructor() : ViewModel() {
|
|||||||
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
||||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
// The visibility reconcile a grant owes (#75) hangs off RootScreen, which
|
||||||
|
// also catches grants made outside the app.
|
||||||
fun onGranted() {
|
fun onGranted() {
|
||||||
_state.value = PermissionUiState.Granted
|
_state.value = PermissionUiState.Granted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -19,6 +20,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ReminderOnboardingViewModel @Inject constructor(
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
private val scanner: ReminderScanner,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
@@ -34,6 +36,18 @@ class ReminderOnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setRemindersEnabled(remindersEnabled)
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
prefs.setReminderOnboardingDone()
|
prefs.setReminderOnboardingDone()
|
||||||
|
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||||
|
// alarm (#75).
|
||||||
|
scanner.scan()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scan after the calendar permission is granted. The launch scan runs
|
||||||
|
* before the grant and bails out without arming anything, so without this
|
||||||
|
* the first alarm waits on the daily worker.
|
||||||
|
*/
|
||||||
|
fun rearmAfterGrant() {
|
||||||
|
scanner.scanInBackground()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
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.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
|
||||||
@@ -11,6 +16,7 @@ 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.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -19,7 +25,11 @@ 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.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -27,48 +37,68 @@ 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.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.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.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.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.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
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.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.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.toJavaLocalDate
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
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 (top-bar entry). Type a query → matching events
|
* Full-text event search (top-bar entry). Type a query → matching events
|
||||||
* (title / location / description) across the whole calendar, newest-relevant
|
* (title / location / description) across the whole calendar, newest-relevant
|
||||||
* first; tap a result to open its detail. A full-screen overlay hosted by
|
* first; tap a result to open its detail. Long-press a result to enter selection
|
||||||
|
* mode and delete several at once (#80). A full-screen overlay hosted by
|
||||||
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -81,8 +111,13 @@ 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) }
|
||||||
|
|
||||||
// Each fresh open starts blank and straight into typing. The ViewModel is
|
// Each fresh open starts blank and straight into typing. The ViewModel is
|
||||||
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
||||||
@@ -93,46 +128,83 @@ fun SearchScreen(
|
|||||||
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, which would read as closing search only to have
|
||||||
|
// it snap back. The two handlers are mutually exclusive on [inSelection].
|
||||||
|
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.predictiveBack(onBack = onBack),
|
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
if (inSelection) {
|
||||||
title = {
|
SelectionTopBar(
|
||||||
InlineTextField(
|
count = selection.size,
|
||||||
value = query,
|
onClose = viewModel::clearSelection,
|
||||||
onValueChange = viewModel::setQuery,
|
onSelectAll = viewModel::selectAll,
|
||||||
placeholder = stringResource(R.string.search_hint),
|
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||||
capitalization = KeyboardCapitalization.None,
|
)
|
||||||
imeAction = ImeAction.Search,
|
} else {
|
||||||
onImeAction = { keyboard?.hide() },
|
TopAppBar(
|
||||||
modifier = Modifier
|
title = {
|
||||||
.fillMaxWidth()
|
InlineTextField(
|
||||||
.focusRequester(focusRequester),
|
value = query,
|
||||||
)
|
onValueChange = viewModel::setQuery,
|
||||||
},
|
placeholder = stringResource(R.string.search_hint),
|
||||||
navigationIcon = {
|
capitalization = KeyboardCapitalization.None,
|
||||||
IconButton(onClick = onBack) {
|
imeAction = ImeAction.Search,
|
||||||
Icon(
|
onImeAction = { keyboard?.hide() },
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
modifier = Modifier
|
||||||
contentDescription = stringResource(R.string.search_back),
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
},
|
navigationIcon = {
|
||||||
actions = {
|
IconButton(onClick = onBack) {
|
||||||
if (query.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { viewModel.setQuery("") }) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.search_clear),
|
contentDescription = stringResource(R.string.search_back),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
actions = {
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
if (query.isNotEmpty()) {
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
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 shrinks the content by the keyboard, so the centered
|
// imePadding shrinks the content by the keyboard, so the centered
|
||||||
@@ -154,19 +226,113 @@ 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(
|
||||||
events = s.events,
|
results = s,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Contextual bar replacing the search field while results are picked. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SelectionTopBar(
|
||||||
|
count: Int,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(pluralStringResource(R.plurals.search_selected_count, count, count)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_selection_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResults(
|
private fun SearchResults(
|
||||||
events: List<EventInstance>,
|
results: SearchUiState.Results,
|
||||||
|
selection: Set<Long>,
|
||||||
|
inSelection: Boolean,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onToggle: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val events = results.events
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||||
@@ -175,11 +341,22 @@ private fun SearchResults(
|
|||||||
items = events,
|
items = events,
|
||||||
key = { _, event -> event.eventId },
|
key = { _, event -> event.eventId },
|
||||||
) { index, event ->
|
) { index, event ->
|
||||||
|
val deletable = results.isDeletable(event)
|
||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +367,11 @@ private fun SearchResultRow(
|
|||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
selected: Boolean = false,
|
||||||
|
inSelection: Boolean = false,
|
||||||
|
selectable: Boolean = true,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
@@ -200,18 +381,79 @@ private fun SearchResultRow(
|
|||||||
summary = searchSummary(event),
|
summary = searchSummary(event),
|
||||||
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(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
trailing = if (inSelection && selectable) {
|
||||||
|
{
|
||||||
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The batch's receipt: how many events went, and whether any refused. Sits on
|
||||||
|
* the FAB band so the shortened list stays visible behind it. Deletes aren't
|
||||||
|
* reversible through the provider, so this carries no Undo.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeleteOutcomeChip(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BulkDeleteUiState.NeedsPermission -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun searchSummary(event: EventInstance): String {
|
private fun searchSummary(event: EventInstance): String {
|
||||||
@@ -220,9 +462,13 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
val start = remember(event.start, zone) {
|
val start = remember(event.start, zone) {
|
||||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||||
}
|
}
|
||||||
val dateText = remember(locale) {
|
// From the shared span rule, not [start]: an all-day event sits at UTC
|
||||||
|
// midnight and would name the day before west of UTC (#82). The clock time
|
||||||
|
// below stays device-zone — it is only rendered for timed events.
|
||||||
|
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
}.format(start)
|
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||||
|
}
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val timeText = if (event.isAllDay) {
|
val timeText = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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.RecurringWriteScope
|
||||||
|
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
|
||||||
@@ -14,12 +16,16 @@ 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 kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -34,7 +40,31 @@ sealed interface SearchUiState {
|
|||||||
data class Empty(val query: String) : SearchUiState
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||||
data class Results(val events: List<EventInstance>) : SearchUiState
|
data class Results(
|
||||||
|
val events: List<EventInstance>,
|
||||||
|
/**
|
||||||
|
* Calendars among the results that can't be written to (WebCal, birthday
|
||||||
|
* mirrors, …). Their rows are excluded from selection rather than failing
|
||||||
|
* at delete time.
|
||||||
|
*/
|
||||||
|
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||||
|
) : SearchUiState {
|
||||||
|
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 mid-flight; nothing further was attempted. */
|
||||||
|
data object NeedsPermission : BulkDeleteUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@@ -47,17 +77,34 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val _query = MutableStateFlow("")
|
private val _query = MutableStateFlow("")
|
||||||
val query: StateFlow<String> = _query.asStateFlow()
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
val state: StateFlow<SearchUiState> = _query
|
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
||||||
.debounce(250L)
|
private val _reload = MutableStateFlow(0)
|
||||||
.map { it.trim() }
|
|
||||||
.distinctUntilChanged()
|
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
|
|
||||||
|
/** 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 results = repository.searchEvents(q)
|
val results = repository.searchEvents(q)
|
||||||
if (results.isEmpty()) SearchUiState.Empty(q)
|
if (results.isEmpty()) {
|
||||||
else SearchUiState.Results(sortNearestFirst(results))
|
SearchUiState.Empty(q)
|
||||||
|
} else {
|
||||||
|
SearchUiState.Results(
|
||||||
|
events = sortNearestFirst(results),
|
||||||
|
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(SearchUiState.Idle) }
|
.catch { emit(SearchUiState.Idle) }
|
||||||
@@ -65,9 +112,101 @@ 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. */
|
||||||
|
fun toggleSelection(eventId: Long) {
|
||||||
|
val current = _selection.value
|
||||||
|
_selection.value = if (eventId in current) current - eventId else current + eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 current selection contains a recurring event, i.e. whether the
|
||||||
|
* batch needs a [RecurringWriteScope] decision before it can run.
|
||||||
|
*/
|
||||||
|
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] applies to the recurring ones only —
|
||||||
|
* a one-off is always removed whole, whatever the batch decision was. Runs
|
||||||
|
* one event 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
|
||||||
|
} 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()
|
||||||
|
|
||||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
|
|||||||
@@ -0,0 +1,505 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appearance: how the app itself looks — theme and colour, the two typeface
|
||||||
|
* roles, and the launcher name. Anything that only changes how a *calendar view*
|
||||||
|
* reads (week start, time format, grid options) lives in [ViewsScreen] instead,
|
||||||
|
* and the widget-only settings in [WidgetsScreen] (#69).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AppearanceScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
|
var showPlainFont by remember { mutableStateOf(false) }
|
||||||
|
var showAppName by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.fontImportFailed.collect {
|
||||||
|
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Theme & colour
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
summary = themeLabel(state.themeMode),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showTheme = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
|
summary = if (state.dynamicColorAvailable) {
|
||||||
|
stringResource(R.string.settings_dynamic_color_summary)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||||
|
},
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.dynamicColor,
|
||||||
|
onCheckedChange = viewModel::setDynamicColor,
|
||||||
|
enabled = state.dynamicColorAvailable,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = if (state.dynamicColorAvailable) {
|
||||||
|
{ viewModel.setDynamicColor(!state.dynamicColor) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_soften_colors),
|
||||||
|
summary = stringResource(R.string.settings_soften_colors_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.softenColors,
|
||||||
|
onCheckedChange = viewModel::setSoftenColors,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setSoftenColors(!state.softenColors) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Fonts — the two Material typeface roles (#19).
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_font_headings),
|
||||||
|
summary = fontLabel(fonts.brand),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showBrandFont = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_font_body),
|
||||||
|
summary = fontLabel(fonts.plain),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showPlainFont = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// App name — the launcher label (#44); its own group, being a
|
||||||
|
// launcher concern rather than app styling.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
summary = launcherNameLabel(launcherName),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showAppName = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAppName) {
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
onDismiss = { showAppName = false },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Both names shown as launcher-mark previews; tapping applies at
|
||||||
|
// once and the picker stays open so the change is visible.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_app_name_summary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
LauncherName.entries.forEach { option ->
|
||||||
|
AppNameOptionCard(
|
||||||
|
name = option,
|
||||||
|
selected = launcherName == option,
|
||||||
|
onClick = { viewModel.setLauncherName(option) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showTheme) {
|
||||||
|
// No thumbnails: picking a theme repaints the app itself. Only which
|
||||||
|
// way "Follow the system" currently falls needs spelling out.
|
||||||
|
val systemDark = isSystemInDarkTheme()
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = ThemeMode.entries,
|
||||||
|
selected = state.themeMode,
|
||||||
|
label = { themeLabel(it) },
|
||||||
|
summary = { mode ->
|
||||||
|
if (mode == ThemeMode.SYSTEM) {
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_theme_system_summary,
|
||||||
|
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelect = viewModel::setThemeMode,
|
||||||
|
onDismiss = { showTheme = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showBrandFont) {
|
||||||
|
FontPicker(
|
||||||
|
title = stringResource(R.string.settings_font_headings),
|
||||||
|
role = FontRole.BRAND,
|
||||||
|
selected = fonts.brand,
|
||||||
|
stamp = fonts.brandStamp,
|
||||||
|
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
||||||
|
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
||||||
|
onDismiss = { showBrandFont = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPlainFont) {
|
||||||
|
FontPicker(
|
||||||
|
title = stringResource(R.string.settings_font_body),
|
||||||
|
role = FontRole.PLAIN,
|
||||||
|
selected = fonts.plain,
|
||||||
|
stamp = fonts.plainStamp,
|
||||||
|
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
||||||
|
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
||||||
|
onDismiss = { showPlainFont = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
|
when (mode) {
|
||||||
|
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||||
|
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||||
|
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The summary label for a stored font token (issue #19). */
|
||||||
|
@Composable
|
||||||
|
private fun fontLabel(token: String): String = when (token) {
|
||||||
|
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
|
||||||
|
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
|
||||||
|
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
|
||||||
|
?: stringResource(R.string.settings_font_system)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The display name for a launcher-label choice (issue #44). */
|
||||||
|
@Composable
|
||||||
|
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||||
|
when (name) {
|
||||||
|
LauncherName.CALENDULA -> R.string.app_name
|
||||||
|
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME types offered to the document picker so it lists only font files. Covers
|
||||||
|
* the modern `font/` types plus the legacy `application/` font aliases some
|
||||||
|
* providers still report. Anything that slips through is still validated by
|
||||||
|
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
|
||||||
|
*/
|
||||||
|
private val FONT_PICKER_MIME_TYPES = arrayOf(
|
||||||
|
"font/ttf",
|
||||||
|
"font/otf",
|
||||||
|
"font/sfnt",
|
||||||
|
"font/collection",
|
||||||
|
"application/x-font-ttf",
|
||||||
|
"application/x-font-otf",
|
||||||
|
"application/font-sfnt",
|
||||||
|
"application/vnd.ms-opentype",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen font chooser for one [FontRole]: the system default, each bundled
|
||||||
|
* font, and "Choose file…" for a .ttf/.otf. A specimen of the current choice
|
||||||
|
* sits above the rows, so the picker stays open on selection and re-renders it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontPicker(
|
||||||
|
title: String,
|
||||||
|
role: FontRole,
|
||||||
|
selected: String,
|
||||||
|
stamp: Int,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onImport: (Uri) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) onImport(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System default + the bundled fonts + the "Choose file…" row.
|
||||||
|
val rowCount = BundledFont.entries.size + 2
|
||||||
|
val isCustom = selected == FONT_CUSTOM_TOKEN
|
||||||
|
// Resolving the custom face stats the disk, so memoise it; [stamp] is bumped
|
||||||
|
// on re-import to refresh a replaced file.
|
||||||
|
val customPreview = remember(role, isCustom, stamp) {
|
||||||
|
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
|
||||||
|
}
|
||||||
|
val selectedFamily = remember(role, selected, stamp) {
|
||||||
|
resolveFontFamily(selected, role, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
|
FontSpecimen(role = role, family = selectedFamily)
|
||||||
|
FontOptionRow(
|
||||||
|
label = stringResource(R.string.settings_font_system),
|
||||||
|
preview = FontFamily.Default,
|
||||||
|
selected = selected == FONT_SYSTEM_TOKEN,
|
||||||
|
position = positionOf(0, rowCount),
|
||||||
|
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
|
||||||
|
)
|
||||||
|
BundledFont.entries.forEachIndexed { index, font ->
|
||||||
|
FontOptionRow(
|
||||||
|
label = stringResource(font.labelRes),
|
||||||
|
preview = font.family,
|
||||||
|
selected = selected == font.token,
|
||||||
|
position = positionOf(index + 1, rowCount),
|
||||||
|
onClick = { onSelect(font.token) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FontOptionRow(
|
||||||
|
label = if (isCustom) {
|
||||||
|
stringResource(R.string.settings_font_custom_selected)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_font_choose_file)
|
||||||
|
},
|
||||||
|
preview = customPreview,
|
||||||
|
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
||||||
|
selected = isCustom,
|
||||||
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
|
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specimen of [family] in the type role [role] governs, using the app's own
|
||||||
|
* styles with only the family swapped. A null [family] is the system typeface.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
|
||||||
|
val isBrand = role == FontRole.BRAND
|
||||||
|
val style = if (isBrand) {
|
||||||
|
MaterialTheme.typography.headlineMedium
|
||||||
|
} else {
|
||||||
|
MaterialTheme.typography.bodyLarge
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body,
|
||||||
|
),
|
||||||
|
style = style.copy(fontFamily = family ?: style.fontFamily),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
// Fixed height, so switching between a wide and a narrow face doesn't
|
||||||
|
// shuffle the option list under it.
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in the [FontPicker]: the font's name, an "Ag" sample in its own
|
||||||
|
* [preview] face (or an [leadingIcon] cue), and a check when selected.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontOptionRow(
|
||||||
|
label: String,
|
||||||
|
preview: FontFamily?,
|
||||||
|
selected: Boolean,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
leadingIcon: ImageVector? = null,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = label,
|
||||||
|
position = position,
|
||||||
|
selected = selected,
|
||||||
|
leading = {
|
||||||
|
if (preview != null) {
|
||||||
|
Text(
|
||||||
|
text = "Ag",
|
||||||
|
fontFamily = preview,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
} else if (leadingIcon != null) {
|
||||||
|
Icon(imageVector = leadingIcon, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailing = if (selected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable launcher-name preview in the App name picker (#44): the app's
|
||||||
|
* launcher mark over the name, framed as a card.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppNameOptionCard(
|
||||||
|
name: LauncherName,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
val borderColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(containerColor)
|
||||||
|
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// The adaptive mark rebuilt as a squircle, as in the onboarding
|
||||||
|
// BrandHero, so it renders identically everywhere.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = launcherNameLabel(name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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.height
|
||||||
|
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.Schedule
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
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.eventFormFieldLabel
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
|
||||||
|
/** New event form: which fields it opens with, and how it behaves. */
|
||||||
|
@Composable
|
||||||
|
internal fun EventFormScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
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(
|
||||||
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
val fields = EventFormField.entries
|
||||||
|
fields.forEachIndexed { index, field ->
|
||||||
|
val checked = field in state.defaultFormFields
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(eventFormFieldLabel(field)),
|
||||||
|
position = positionOf(index, fields.size),
|
||||||
|
// The same icon the field carries in the new-event form.
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = eventFormFieldIcon(field),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-focus the title on a new event (#10), on by default.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_autofocus_title),
|
||||||
|
summary = stringResource(R.string.settings_autofocus_title_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Keyboard,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.autofocusEventTitle,
|
||||||
|
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// CalDAV); off by default, since it may not survive their next sync.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_color_unsupported),
|
||||||
|
summary = stringResource(R.string.settings_color_unsupported_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.allowColorOnUnsupportedCalendars,
|
||||||
|
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.setAllowColorOnUnsupportedCalendars(
|
||||||
|
!state.allowColorOnUnsupportedCalendars,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
|
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.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||||
|
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||||
|
* the pref is set either way; the OS permission is the real gate.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun NotificationsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenSpecialDates: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||||
|
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
||||||
|
viewModel.setRemindersEnabled(enabled)
|
||||||
|
val needsPermission = enabled &&
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
if (needsPermission) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||||
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
|
var showSnooze by remember { mutableStateOf(false) }
|
||||||
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
|
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||||
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reminders),
|
||||||
|
summary = stringResource(R.string.settings_reminders_hint),
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||||
|
},
|
||||||
|
onClick = { toggleReminders(!state.remindersEnabled) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAllDayReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_allday_reminder_time),
|
||||||
|
summary = stringResource(
|
||||||
|
R.string.settings_allday_reminder_time_hint,
|
||||||
|
settingsTimeOfDay(state.allDayReminderTimeMinutes),
|
||||||
|
),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showAllDayReminderTime = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reliability is a soft, optional battery-optimisation exemption via
|
||||||
|
// a system-settings deep-link, shown as live status.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reliable_delivery),
|
||||||
|
summary = if (batteryExempt) {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_exempt)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_hint)
|
||||||
|
},
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = if (batteryExempt) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
|
)
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
summary = durationLabel(state.snoozeMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showSnooze = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-calendar overrides, folded behind one header. Each writable
|
||||||
|
// calendar may keep, drop or replace the global default, separately for
|
||||||
|
// timed and all-day events.
|
||||||
|
if (state.writableCalendars.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = calendarSectionExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
state.writableCalendars.forEach { calendar ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
// Special-dates calendars own their reminders in their
|
||||||
|
// own section — link there instead.
|
||||||
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onOpenSpecialDates,
|
||||||
|
)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val expanded = calendar.id in expandedCalendars
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expandedCalendars = if (expanded) {
|
||||||
|
expandedCalendars - calendar.id
|
||||||
|
} else {
|
||||||
|
expandedCalendars + calendar.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
|
)
|
||||||
|
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSnooze) {
|
||||||
|
DurationPicker(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
presets = SNOOZE_PRESETS,
|
||||||
|
selected = state.snoozeMinutes,
|
||||||
|
label = { durationLabel(it) },
|
||||||
|
// No inherit row here, so a pick is never null.
|
||||||
|
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
|
||||||
|
onDismiss = { showSnooze = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDefaultReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
presets = REMINDER_PRESETS,
|
||||||
|
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||||
|
onDismiss = { showDefaultReminder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
|
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||||
|
onDismiss = { showAllDayReminder = false },
|
||||||
|
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAllDayReminderTime) {
|
||||||
|
TimePickerAlert(
|
||||||
|
initial = LocalTime(
|
||||||
|
state.allDayReminderTimeMinutes / 60,
|
||||||
|
state.allDayReminderTimeMinutes % 60,
|
||||||
|
),
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
|
||||||
|
showAllDayReminderTime = false
|
||||||
|
},
|
||||||
|
onDismiss = { showAllDayReminderTime = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
overrideDialog?.let { target ->
|
||||||
|
val map = if (target.isAllDay) {
|
||||||
|
state.perCalendarAllDayReminderOverride
|
||||||
|
} else {
|
||||||
|
state.perCalendarReminderOverride
|
||||||
|
}
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(
|
||||||
|
if (target.isAllDay) {
|
||||||
|
R.string.settings_default_reminder_allday
|
||||||
|
} else {
|
||||||
|
R.string.settings_default_reminder
|
||||||
|
},
|
||||||
|
),
|
||||||
|
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||||
|
selected = map.reminderOverrideFor(target.calendarId),
|
||||||
|
allowInherit = true,
|
||||||
|
onSelect = {
|
||||||
|
if (target.isAllDay) {
|
||||||
|
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
|
||||||
|
} else {
|
||||||
|
viewModel.setCalendarReminderOverride(target.calendarId, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { overrideDialog = null },
|
||||||
|
leadTimeSummary = if (target.isAllDay) {
|
||||||
|
allDayFiringTimeSummary(state.allDayReminderTimeMinutes)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
|
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||||
|
private fun List<Int>.toReminderChoice(): ReminderOverride =
|
||||||
|
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
|
||||||
|
|
||||||
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
|
private fun ReminderOverride.toMinutesList(): List<Int> =
|
||||||
|
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||||
|
|
||||||
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
|
@Composable
|
||||||
|
private fun calendarOverrideSummary(
|
||||||
|
choice: ReminderOverride,
|
||||||
|
globalDefault: List<Int>,
|
||||||
|
): String = when (choice) {
|
||||||
|
ReminderOverride.Inherit ->
|
||||||
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
|
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
exempt = isIgnoringBatteryOptimizations(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
return exempt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||||
|
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return power.isIgnoringBatteryOptimizations(context.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the direct `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog, falling back to
|
||||||
|
* the optimisation list if the OS refuses it.
|
||||||
|
*/
|
||||||
|
private fun openBatteryOptimizationSettings(context: Context) {
|
||||||
|
val direct = Intent(
|
||||||
|
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||||
|
"package:${context.packageName}".toUri(),
|
||||||
|
)
|
||||||
|
if (runCatching { context.startActivity(direct) }.isFailure) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "past events" chooser for the Agenda (#69 follow-up). A stand-in agenda
|
||||||
|
* day sits above the options and re-renders as you pick one, using the Agenda's
|
||||||
|
* own rows and filtering. A preview picker, so it stays open on selection.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PastEventsPicker(
|
||||||
|
selected: PastEventDisplay,
|
||||||
|
onSelect: (PastEventDisplay) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = PastEventDisplay.entries
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.height(PREVIEW_HEIGHT),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = selected,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = "past-events-preview",
|
||||||
|
) { shown ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
PastEventsPreview(mode = shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PickerDescription(stringResource(R.string.settings_past_events_hint))
|
||||||
|
options.forEachIndexed { index, mode ->
|
||||||
|
val isSelected = mode == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(pastEventDisplayLabelRes(mode)),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(mode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in agenda day in a given [mode]: three events on today, the first two
|
||||||
|
* finished. Times are fixed rather than clock-derived, so it reads the same at
|
||||||
|
* any hour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val titles = listOf(
|
||||||
|
stringResource(R.string.settings_past_events_sample_morning),
|
||||||
|
stringResource(R.string.settings_past_events_sample_midday),
|
||||||
|
stringResource(R.string.settings_past_events_sample_evening),
|
||||||
|
)
|
||||||
|
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
|
||||||
|
// The same three-way split AgendaContent makes.
|
||||||
|
val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clipToBounds()
|
||||||
|
// A picture, not a control: take the taps the fake rows would
|
||||||
|
// otherwise handle, and keep it out of the reading order. Taps only —
|
||||||
|
// consuming drags would stop the picker scrolling from here.
|
||||||
|
.pointerInput(Unit) { detectTapGestures { } }
|
||||||
|
.clearAndSetSemantics { },
|
||||||
|
) {
|
||||||
|
AgendaDayHeader(date = today, today = today, onOpenDay = {})
|
||||||
|
visible.forEachIndexed { index, row ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = row.event,
|
||||||
|
day = today,
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, visible.size),
|
||||||
|
dimmed = mode == PastEventDisplay.DIM && row.hasPassed,
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One stand-in row plus whether it counts as finished. */
|
||||||
|
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's stand-in events, [titles] in order: two finished and one to come.
|
||||||
|
* Colours are raw ARGB, as the provider hands them out.
|
||||||
|
*/
|
||||||
|
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
|
||||||
|
listOf(
|
||||||
|
sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true),
|
||||||
|
sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true),
|
||||||
|
sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sampleRow(
|
||||||
|
id: Long,
|
||||||
|
title: String,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
startHour: Int,
|
||||||
|
endHour: Int,
|
||||||
|
color: Int,
|
||||||
|
hasPassed: Boolean,
|
||||||
|
): SampleRow = SampleRow(
|
||||||
|
event = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = day.atTime(startHour, 0).toInstant(zone),
|
||||||
|
end = day.atTime(endHour, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = color,
|
||||||
|
location = null,
|
||||||
|
),
|
||||||
|
hasPassed = hasPassed,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Room for the day header plus its three two-line rows, so hiding shortens the
|
||||||
|
* list inside a box that keeps its height instead of the page jumping a row. */
|
||||||
|
private val PREVIEW_HEIGHT = 288.dp
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each
|
||||||
|
* sub-screen owns whatever only it uses; anything two of them need lives here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accent for a leading icon chip. The chips are a scanning aid, so no two rows
|
||||||
|
* in one group share an accent; [Neutral] is a step back for reference rows
|
||||||
|
* rather than a fourth colour to rotate through.
|
||||||
|
*/
|
||||||
|
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
|
||||||
|
|
||||||
|
/** Leading circular icon chip, coloured by an M3 container/on-container pair. */
|
||||||
|
@Composable
|
||||||
|
internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val (background, iconColor) = when (accent) {
|
||||||
|
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
|
||||||
|
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||||
|
ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||||
|
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconColor,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small primary-coloured group label, matching the Calendars settings screen.
|
||||||
|
* Inset to [GroupedListInset] so it shares the cards' left margin.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsHint(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openUrl(context: Context, url: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||||
|
@Composable
|
||||||
|
internal fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||||
|
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||||
|
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day-scale lead times for all-day defaults and contact special dates. */
|
||||||
|
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||||
|
|
||||||
|
/** A minute-of-day in the app's own 12/24-hour convention ([LocalUse24HourFormat]). */
|
||||||
|
@Composable
|
||||||
|
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
|
||||||
|
formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second line for an all-day lead-time row: the clock time it fires at. The
|
||||||
|
* offset only decides which day; the hour comes from the global all-day setting
|
||||||
|
* (see [de.jeanlucmakiola.calendula.domain.reminders.planReminders]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {
|
||||||
|
val summary = stringResource(
|
||||||
|
R.string.settings_allday_reminder_fires_at,
|
||||||
|
settingsTimeOfDay(allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
return { summary }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user