Compare commits
78 Commits
v2.4.0
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8e3ca69e | |||
| 3b5c0be765 | |||
| 013efef29e | |||
| 5457a34282 | |||
| 82e93fda4c | |||
| 871cc34cab | |||
| 5b99da32b0 | |||
| b8a7191fbe | |||
| 6b0eb48056 | |||
| 572a4734ea | |||
| 7f8a9069c0 | |||
| 22c4eded96 | |||
| 9839f9cd38 | |||
| c0e27133b4 | |||
| a4e0ec3fde | |||
| ac17343747 | |||
| b0f34ff18a | |||
| ab8a9e3401 | |||
| 92ec718b28 | |||
| 4ad1c09f8f | |||
| 5e8cb686e9 | |||
| 62ebd48e3c | |||
| c3dbcca989 | |||
| 37452be3bd | |||
| 006cce96a9 | |||
| 88d770bebe | |||
| b6bee55fc6 | |||
| 7ab277b434 | |||
| d0b6823385 | |||
| 5c80f99179 | |||
| 0875e69c2d | |||
| ac572da40a | |||
| f27811d215 | |||
| 36e2199dff | |||
| eb51175ceb | |||
| bc59200f77 | |||
| ed6b0fa89f | |||
| c503199e06 | |||
| f3f155b5ff | |||
| 294524a943 | |||
| ef6f1891b3 | |||
| 9fb3c780f1 | |||
| 8a80478555 | |||
| df203c0b6b | |||
| 0c95479051 | |||
| 221313178f | |||
| 297a2350e3 | |||
| 997ee44792 | |||
| 81baadfaf3 | |||
| 35022267dc | |||
| 588e024036 | |||
| eeef089e4a | |||
| 9023899ddb | |||
| 5ab3344f8c | |||
| 2431abe912 | |||
| 2f153fef56 | |||
| 701077f25b | |||
| 290a905f8b | |||
| d20d446cbe | |||
| 6e14d5964b | |||
| 3dfc96718c | |||
| e1c2e9f2e5 | |||
| 90b219bdad | |||
| 233a9b03a3 | |||
| 0b683d374f | |||
| 64d0a89b28 | |||
| 7285e274df | |||
| 788ca3906e | |||
| bab6fd175a | |||
| 3d5cc55ef1 | |||
| 111b3782b0 | |||
| cf380b6eab | |||
| 9177a926df | |||
| 5e6defd4c7 | |||
| 6e7ae3e60d | |||
| b0b30eef91 | |||
| 8b25c9be39 | |||
| 2943f3945d |
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something doesn't work the way it should
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
---
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### What you expected
|
||||||
|
|
||||||
|
|
||||||
|
### Steps to reproduce
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
26
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
26
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Crash report
|
||||||
|
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
||||||
|
title: "Crash: "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- crash
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Thanks for reporting a crash in Calendula!
|
||||||
|
|
||||||
|
If the app prefilled this for you, the crash report is already below — just add
|
||||||
|
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||||
|
into the code block. The report contains only app/Android/device versions and the
|
||||||
|
stack trace — no personal data or calendar content.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### Crash report
|
||||||
|
|
||||||
|
```
|
||||||
|
(paste the crash report here)
|
||||||
|
```
|
||||||
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea or improvement
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
### What would you like Calendula to do?
|
||||||
|
|
||||||
|
|
||||||
|
### Why — what problem does it solve?
|
||||||
|
|
||||||
|
|
||||||
|
### Anything else
|
||||||
|
<!-- mockups, examples from other apps, alternatives you considered -->
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
|
# One gate per pull request. Branch pushes no longer trigger CI on their own,
|
||||||
|
# so a change is built once on its PR (covering feature -> release/* and
|
||||||
|
# release/* -> main) instead of once per push and again on the merge to main.
|
||||||
|
# The merge itself is handled by release.yaml, which only does heavy work when
|
||||||
|
# the merge actually cuts a release.
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
tags-ignore:
|
|
||||||
- '**'
|
|
||||||
|
|
||||||
# Cancel superseded runs on the same branch.
|
# Cancel superseded runs for the same PR.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Single job named `ci` so the required "CI" status check is always reported,
|
||||||
|
# even for docs-only PRs: those just skip the Android build and the job still
|
||||||
|
# succeeds (fast green check) instead of being filtered out and leaving the
|
||||||
|
# required check pending forever.
|
||||||
ci:
|
ci:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
@@ -21,14 +26,53 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history so the base..HEAD diff below has a merge-base.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||||
|
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||||
|
# change scope so a regression can't slip through on a "docs-only" PR.
|
||||||
|
- name: Reproducible-release invariant
|
||||||
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
|
# Decide whether anything that affects the app build changed. Docs,
|
||||||
|
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
||||||
|
# Gradle work below but still report a green `ci`.
|
||||||
|
- name: Classify change scope
|
||||||
|
id: scope
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BASE="${{ github.base_ref }}"
|
||||||
|
# Full (not --depth=1) base fetch so the merge-base is present even when
|
||||||
|
# the PR branch forked several commits back; a shallow tip has no merge
|
||||||
|
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||||
|
git fetch --no-tags origin "$BASE"
|
||||||
|
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
|
||||||
|
if [ -z "$MB" ]; then
|
||||||
|
# No common ancestor available — don't risk skipping the build.
|
||||||
|
echo "No merge base with origin/$BASE — running the full build to be safe."
|
||||||
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||||
|
echo "Changed files:"; echo "$CHANGED"
|
||||||
|
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
||||||
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Docs/metadata-only change — skipping the Android build."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
@@ -36,12 +80,14 @@ jobs:
|
|||||||
packages: ''
|
packages: ''
|
||||||
|
|
||||||
- name: Setup Android SDK cache
|
- name: Setup Android SDK cache
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/android-sdk
|
path: /opt/android-sdk
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
@@ -50,6 +96,7 @@ jobs:
|
|||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
- name: Setup Gradle cache
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -60,21 +107,25 @@ jobs:
|
|||||||
${{ runner.os }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
# No --no-daemon: the daemon lives only as long as this job container
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
# and lets the following steps skip JVM startup + reconfiguration.
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
- name: Lint (debug variant only)
|
- name: Lint (debug variant only)
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew lintDebug
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew assembleDebug
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Trivy filesystem scan
|
- name: Trivy filesystem scan
|
||||||
if: github.ref == 'refs/heads/main'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|||||||
@@ -1,75 +1,84 @@
|
|||||||
name: Release — F-Droid repo + Gitea release
|
name: Release — F-Droid repo + Gitea release
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
||||||
|
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
|
||||||
|
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
|
||||||
|
# merges (no version bump) fall through `detect` and do nothing.
|
||||||
|
#
|
||||||
|
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
|
||||||
|
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
||||||
|
# without building an APK or creating a release. Used for key rotation / repo
|
||||||
|
# recovery.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches: [main]
|
||||||
- '*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
# Cheap gate: resolve the version from the committed build.gradle and decide
|
||||||
|
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||||
|
# heavy job from running on every merge to main.
|
||||||
|
detect:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
outputs:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
version_code: ${{ steps.v.outputs.version_code }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Resolve version and whether it is a new release
|
||||||
uses: actions/setup-java@v4
|
id: v
|
||||||
with:
|
env:
|
||||||
distribution: 'zulu'
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
java-version: '17'
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
packages: ''
|
|
||||||
|
|
||||||
- name: Setup Android SDK cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: /opt/android-sdk
|
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
set -e
|
||||||
sdkmanager \
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
"platform-tools" \
|
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||||
"platforms;android-37.0" \
|
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||||
"build-tools;36.0.0"
|
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||||
|
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved version $VERSION (code $VERSION_CODE)"
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "Manual dispatch — re-sign path, not a release."
|
||||||
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# A tag for this version already existing means the release shipped on
|
||||||
|
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||||
|
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "Tag v$VERSION already exists — nothing to release."
|
||||||
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "No tag for v$VERSION yet — cutting the release."
|
||||||
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
uses: actions/cache@v4
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
with:
|
# re-uploads the existing index (recovery path).
|
||||||
path: |
|
release:
|
||||||
~/.gradle/caches
|
needs: detect
|
||||||
~/.gradle/wrapper
|
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-
|
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
# Lint already enforced on every push to main via ci.yaml.
|
|
||||||
# Release sanity only re-runs tests + a debug build to catch
|
|
||||||
# any tag-resolved drift (e.g. version code substitution issues).
|
|
||||||
|
|
||||||
- name: Unit tests
|
|
||||||
run: ./gradlew testDebugUnitTest
|
|
||||||
|
|
||||||
- name: Assemble debug APK (sanity)
|
|
||||||
run: ./gradlew assembleDebug
|
|
||||||
|
|
||||||
build-and-deploy:
|
|
||||||
needs: ci
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
VERSION: ${{ needs.detect.outputs.version }}
|
||||||
|
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||||
|
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -121,31 +130,26 @@ jobs:
|
|||||||
$SUDO apk add --no-cache jq
|
$SUDO apk add --no-cache jq
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
|
- name: Grant execute permission for gradlew
|
||||||
# not a tag) these are skipped: the job then just re-signs the existing
|
run: chmod +x ./gradlew
|
||||||
# index with the configured repo key and re-uploads — used for key
|
|
||||||
# rotation / repo recovery without publishing a new APK.
|
# The committed versionName is the source of truth. Pin versionCode to the
|
||||||
- name: Set version from git tag
|
# value derived from it so the published APK's code is always
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||||
|
- name: Pin versionCode to versionName
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
VERSION="${RAW_TAG#v}"
|
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
|
||||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
|
||||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
|
||||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
|
||||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
|
||||||
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
|
|
||||||
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
|
|
||||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||||
# Export for later steps (F-Droid changelog, mapping asset name).
|
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
# Test the exact commit being shipped (only on a real release).
|
||||||
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
|
- name: Unit tests
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
@@ -160,11 +164,8 @@ jobs:
|
|||||||
storeFile=upload-keystore.jks
|
storeFile=upload-keystore.jks
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: env.IS_RELEASE == 'true'
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Setup F-Droid Server Tools
|
- name: Setup F-Droid Server Tools
|
||||||
@@ -202,8 +203,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
||||||
# one: a fresh key changes the repo fingerprint and breaks every
|
# one: a fresh key changes the repo fingerprint and breaks every
|
||||||
# user's pinned repo. (Replaces the old `fdroid update --create-key`
|
# user's pinned repo.
|
||||||
# path, which silently rotated the key on a wiped server.)
|
|
||||||
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
|
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
|
||||||
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
|
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
|
||||||
echo "Refusing to continue — will not auto-generate a new repo key." >&2
|
echo "Refusing to continue — will not auto-generate a new repo key." >&2
|
||||||
@@ -216,42 +216,33 @@ jobs:
|
|||||||
mkdir -p fdroid/repo/icons
|
mkdir -p fdroid/repo/icons
|
||||||
|
|
||||||
- name: Copy new APK to repo
|
- name: Copy new APK to repo
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
mkdir -p fdroid/repo
|
mkdir -p fdroid/repo
|
||||||
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||||
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
|
|
||||||
if [ -z "$SAFE_REF_NAME" ]; then
|
|
||||||
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
|
|
||||||
fi
|
|
||||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
|
|
||||||
|
|
||||||
- name: Copy metadata to F-Droid repo
|
# 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
|
||||||
|
# regenerates it from 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
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||||
|
|
||||||
|
- name: Build F-Droid metadata from fastlane (single source of truth)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p fdroid/metadata
|
mkdir -p fdroid/metadata
|
||||||
cp -r fdroid-metadata/* fdroid/metadata/
|
# App-level control file (Categories/License/links) for the self-hosted
|
||||||
|
# repo's `fdroid update`.
|
||||||
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
|
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/
|
||||||
# section written to changelogs/<versionCode>.txt (same extraction as the
|
# Localized text + graphics + per-version changelogs come from the SAME
|
||||||
# Gitea release notes). en-US only — F-Droid falls back to it for locales
|
# fastlane tree the official F-Droid repo harvests from source,
|
||||||
# without their own changelog. fdroid update bakes this into the index.
|
# transformed into the F-Droid repo "localized" layout. One source of
|
||||||
- name: Generate F-Droid changelog for this version
|
# truth, both channels.
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
bash scripts/fastlane_to_fdroid_localized.sh \
|
||||||
run: |
|
fastlane/metadata/android \
|
||||||
set -e
|
fdroid/metadata/de.jeanlucmakiola.calendula
|
||||||
awk -v ver="$VERSION" '
|
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > /tmp/changelog.txt
|
|
||||||
sed -i -e '/./,$!d' /tmp/changelog.txt
|
|
||||||
if [ ! -s /tmp/changelog.txt ]; then
|
|
||||||
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
|
|
||||||
fi
|
|
||||||
CL_DIR="fdroid/metadata/de.jeanlucmakiola.calendula/en-US/changelogs"
|
|
||||||
mkdir -p "$CL_DIR"
|
|
||||||
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
|
|
||||||
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
|
|
||||||
|
|
||||||
- name: Generate F-Droid Index
|
- name: Generate F-Droid Index
|
||||||
run: |
|
run: |
|
||||||
@@ -272,97 +263,46 @@ jobs:
|
|||||||
SFTP
|
SFTP
|
||||||
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
|
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
|
||||||
# per-version changelogs) so changelog history survives across
|
# per-version changelogs) so changelog history survives across
|
||||||
# releases. keystore.p12 and config.yml are NEVER uploaded, so they
|
# releases. keystore.p12 and config.yml are NEVER uploaded.
|
||||||
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
|
|
||||||
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
|
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
|
||||||
|
|
||||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
# The APK is published and the index re-signed — now record the release.
|
||||||
# Attached to the Gitea release (it's not an APK, so it fits the
|
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||||
# no-binaries rule). Best-effort: never fail a release over it.
|
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||||
- name: Attach R8 mapping to Gitea release
|
# failure before here leaves no tag, so re-running the workflow retries).
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
- name: Create tag + Gitea release
|
||||||
continue-on-error: true
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
TAG="v$VERSION"
|
||||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
# Notes = this version's CHANGELOG section.
|
||||||
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
|
|
||||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
|
||||||
# The release is created by the gitea-release job; ensure it exists
|
|
||||||
# (idempotent) so this job doesn't race it to a 404.
|
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
|
||||||
if [ -z "$ID" ]; then
|
|
||||||
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
|
|
||||||
"$API/releases" | jq -r '.id // empty')
|
|
||||||
fi
|
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
|
||||||
# Replace any prior asset of the same name (re-run safe).
|
|
||||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
||||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
|
||||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
|
||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@/tmp/$ASSET" \
|
|
||||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
|
||||||
|
|
||||||
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
|
|
||||||
# notes. Deliberately no APK assets — distribution stays with the F-Droid
|
|
||||||
# repo; the release is the human-readable record. Gated on the tests-only
|
|
||||||
# ci job (not the deploy) so notes appear even if the F-Droid upload has
|
|
||||||
# an infrastructure hiccup.
|
|
||||||
gitea-release:
|
|
||||||
needs: ci
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Extract changelog section for this tag
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
# Everything between "## [<version>]" and the next "## [" heading.
|
|
||||||
awk -v ver="$VERSION" '
|
awk -v ver="$VERSION" '
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||||
/^## \[/ { flag = 0 }
|
/^## \[/ { flag = 0 }
|
||||||
flag' CHANGELOG.md > release-notes.md
|
flag' CHANGELOG.md > release-notes.md
|
||||||
# Trim leading blank lines.
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
sed -i -e '/./,$!d' release-notes.md
|
||||||
if [ ! -s release-notes.md ]; then
|
if [ ! -s release-notes.md ]; then
|
||||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
||||||
fi
|
fi
|
||||||
echo "--- release notes ---"
|
python3 - "$TAG" "$SHA" <<'PY' > payload.json
|
||||||
cat release-notes.md
|
|
||||||
|
|
||||||
- name: Create Gitea release
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
python3 - "$TAG" <<'PY' > payload.json
|
|
||||||
import json, sys
|
import json, sys
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"tag_name": sys.argv[1],
|
"tag_name": sys.argv[1],
|
||||||
|
"target_commitish": sys.argv[2],
|
||||||
"name": sys.argv[1],
|
"name": sys.argv[1],
|
||||||
"body": open("release-notes.md").read(),
|
"body": open("release-notes.md").read(),
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
}))
|
}))
|
||||||
PY
|
PY
|
||||||
# Upsert: the build-and-deploy job may have created a bare release
|
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||||
# first (to attach the mapping asset), so PATCH the notes if it
|
# else POST a new one (which also creates the tag at target_commitish).
|
||||||
# exists, otherwise POST a new one. Both paths are re-run safe.
|
|
||||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||||
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true)
|
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
||||||
if [ -n "$ID" ]; then
|
if [ -n "$ID" ]; then
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
@@ -376,6 +316,33 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
cat response.json
|
cat response.json
|
||||||
if [ "$CODE" != "$OK" ]; then
|
if [ "$CODE" != "$OK" ]; then
|
||||||
echo "Release upsert failed with HTTP $CODE (expected $OK)"
|
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "Created/updated release $TAG at $SHA"
|
||||||
|
|
||||||
|
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||||
|
# Attached to the release (it's not an APK, so it fits the no-binaries
|
||||||
|
# rule). Best-effort: never fail a release over it.
|
||||||
|
- name: Attach R8 mapping to Gitea release
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
MAP="app/build/outputs/mapping/release/mapping.txt"
|
||||||
|
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
||||||
|
TAG="v$VERSION"
|
||||||
|
ASSET="mapping-${VERSION}.txt.gz"
|
||||||
|
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||||
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||||
|
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
||||||
|
# Replace any prior asset of the same name (re-run safe).
|
||||||
|
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||||
|
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
||||||
|
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||||
|
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@/tmp/$ASSET" \
|
||||||
|
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||||
|
|||||||
42
.gitea/workflows/renovate.yml
Normal file
42
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||||
|
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||||
|
schedule:
|
||||||
|
- cron: '0 5 * * 1'
|
||||||
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Never let two Renovate runs touch the repo at once.
|
||||||
|
concurrency:
|
||||||
|
group: renovate
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: docker
|
||||||
|
# Run the Renovate image *as* the job container and invoke the `renovate`
|
||||||
|
# binary directly. The renovatebot/github-action wrapper is a thin Node
|
||||||
|
# action that shells out to `docker run …` — it needs a Docker CLI + socket
|
||||||
|
# inside the job, which the Gitea runner's plain node container has not, so
|
||||||
|
# it died on "Unable to locate executable file: docker". Running the image
|
||||||
|
# directly drops the docker-in-docker requirement entirely.
|
||||||
|
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||||
|
container:
|
||||||
|
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||||
|
steps:
|
||||||
|
- name: Run Renovate
|
||||||
|
run: renovate
|
||||||
|
env:
|
||||||
|
# Self-hosted Gitea, not github.com.
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
||||||
|
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
# Scope to this repo only — no org-wide autodiscovery.
|
||||||
|
RENOVATE_AUTODISCOVER: 'false'
|
||||||
|
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
||||||
|
# Commits/PRs authored as the bot, not a real maintainer.
|
||||||
|
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||||
|
LOG_LEVEL: info
|
||||||
37
.gitea/workflows/translations.yaml
Normal file
37
.gitea/workflows/translations.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Translations
|
||||||
|
|
||||||
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'app/src/main/res/values*/strings.xml'
|
||||||
|
- 'app/src/main/res/xml/locales_config.xml'
|
||||||
|
- 'scripts/check_translations.py'
|
||||||
|
- '.gitea/workflows/translations.yaml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: translations-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure python3
|
||||||
|
run: |
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update && apt-get install -y python3
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache python3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Check translation parity
|
||||||
|
run: python3 scripts/check_translations.py
|
||||||
@@ -169,7 +169,9 @@ unblock a later item. Order is a plan, not a contract — revisit after each lan
|
|||||||
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||||
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
||||||
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
||||||
5. **Duplicate event** *(next)* — detail action → prefilled create form; near-free on the tap-to-create prefill infra
|
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
|
||||||
|
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
|
||||||
|
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
|
||||||
|
|
||||||
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||||
|
|
||||||
@@ -223,28 +225,63 @@ Out of scope (no new settings *features* here) — this is a structure + style
|
|||||||
pass on the existing controls; new toggles ride in with their own features.
|
pass on the existing controls; new toggles ride in with their own features.
|
||||||
|
|
||||||
**Tier 2 — navigation & daily-driver completeness**
|
**Tier 2 — navigation & daily-driver completeness**
|
||||||
5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap
|
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
|
||||||
6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget
|
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
|
||||||
|
|
||||||
**Tier 3 — platform reach (depends on Tier 2)**
|
**Tier 3 — platform reach (depends on Tier 2)**
|
||||||
7. Home-screen widget — built on the agenda data source from #6
|
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
||||||
8. App shortcuts (launcher long-press → New event); cheap, optional quick-settings tile
|
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
||||||
|
|
||||||
**Tier 4 — interop & bigger-ticket**
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
9. Share event as .ics + receive/open .ics into a prefilled create form
|
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||||
10. Default reminder applied to new events; then snooze/dismiss notification actions
|
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||||
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||||
|
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
|
||||||
|
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||||
|
four surfaces all shipped: single-event share + whole-calendar backup
|
||||||
|
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
|
||||||
|
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
|
||||||
|
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
|
||||||
|
then `feat/ics-import` (parser, restore, dedup by UID). Import is
|
||||||
|
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
|
||||||
|
/ guest lists it can't model. Plans:
|
||||||
|
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
|
||||||
|
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* —
|
||||||
|
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
|
||||||
|
decision (snooze must re-fire an alarm).
|
||||||
|
|
||||||
|
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
||||||
|
12. **Attendee editing** *(next — promoted from the gated "Locations & People"
|
||||||
|
bucket, owner says high-importance 2026-06-22)* — attendees are already
|
||||||
|
*read* (queried, mapped, shown on the detail screen since v0.6) but the
|
||||||
|
event form can't *write* them, the last big read-only gap in the event
|
||||||
|
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
|
||||||
|
typed email (contact-picker entry can follow later), edit/remove rows, set
|
||||||
|
role (required / optional) — writing `CalendarContract.Attendees` rows on
|
||||||
|
insert + dirty-checked update, mirroring the reminders-diff pattern.
|
||||||
|
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
|
||||||
|
*invitation* behavior that differs by backend (Google auto-emails invites;
|
||||||
|
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
|
||||||
|
decide and document what Calendula promises before building. Full sketch in
|
||||||
|
"Attendee editing" under Locations & People below.
|
||||||
|
|
||||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||||
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
||||||
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
**Bottom — deprioritised, not important**
|
||||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
- Duplicate event (detail action → prefilled create form) — moved here
|
||||||
|
2026-06-17; cheap but low value, pick up only if asked
|
||||||
|
|
||||||
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||||
whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
full-text search. Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
|
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||||
|
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected).
|
||||||
|
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap
|
||||||
|
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
||||||
|
(quick-settings tile, now-line, week numbers in month, full-text search).
|
||||||
|
|
||||||
## Navigation & views
|
## Navigation & views
|
||||||
|
|
||||||
@@ -254,14 +291,17 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
- Agenda view (fourth view: upcoming events grouped by day; also the
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
natural data source for a future widget)
|
natural data source for a future widget)
|
||||||
- Jump to date — drawer date picker (un-cut from V1)
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
|
- Current-time "now" line in day/week — standard in every calendar, cheap,
|
||||||
|
currently absent. Daily-driver polish.
|
||||||
|
- Week numbers in the **month** grid — week view already shows the badge
|
||||||
|
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
||||||
- Pinch-to-zoom time scale in day/week
|
- Pinch-to-zoom time scale in day/week
|
||||||
- Tablet / foldable layouts *(was v3.0)*
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
- Full-text search *(was v3.0)*
|
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
|
||||||
|
with real event history, finding an event is core completeness, not optional.
|
||||||
|
|
||||||
## Event editing & creation
|
## Event editing & creation
|
||||||
|
|
||||||
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
|
|
||||||
dialog) — big-ticket, own slice
|
|
||||||
- Duplicate event (detail action → prefilled create form)
|
- Duplicate event (detail action → prefilled create form)
|
||||||
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
||||||
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
||||||
@@ -291,23 +331,124 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
go/no-go gate as the OSM/INTERNET item below.
|
go/no-go gate as the OSM/INTERNET item below.
|
||||||
- Move event to another calendar (copy+delete model with a consequences
|
- Move event to another calendar (copy+delete model with a consequences
|
||||||
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||||
|
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** —
|
||||||
|
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
|
||||||
|
backup. Settings → Calendars → Export writes every event to a user-chosen
|
||||||
|
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||||
|
UID). Closed the silent data-loss gap.
|
||||||
|
|
||||||
## Reminders, round two
|
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
||||||
|
|
||||||
|
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||||
|
the "Calendula is your only calendar app" promise.
|
||||||
|
|
||||||
|
**Built in this slice (A + the safe half of B):** global timed default reminder
|
||||||
|
+ a **separate all-day default** (day-scale lead times) + per-calendar override
|
||||||
|
(timed events), applied on create with manual-edit / calendar-switch / all-day-
|
||||||
|
toggle handling; three pickers + per-calendar override list in Settings →
|
||||||
|
Notifications; battery-optimisation exemption row (status + system deep-link, no
|
||||||
|
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
|
||||||
|
Resolution model: all-day events use the all-day global default outright;
|
||||||
|
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
|
||||||
|
applied: form-reset state race, label-fn consolidation with the detail screen,
|
||||||
|
inline wrapper + single combined flow read.
|
||||||
|
|
||||||
|
**Deliberately deferred (documented decisions, not oversights):**
|
||||||
|
- *Absolute time-of-day for all-day reminders* — the all-day default is still
|
||||||
|
minutes-before-midnight (day-scale presets), not "9am the day before" (open
|
||||||
|
decision #2's richer half). Per-calendar all-day overrides also deferred.
|
||||||
|
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
|
||||||
|
(open decision #1). The battery exemption is the reliability lever; no
|
||||||
|
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
|
||||||
|
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
|
||||||
|
exemption lives only in Settings for now (onboarding flow untouched to keep
|
||||||
|
the change reviewable).
|
||||||
|
|
||||||
|
### A. Default reminders (global + per-calendar override)
|
||||||
|
|
||||||
|
**No provider backing.** `CalendarContract` has no column that auto-applies a
|
||||||
|
default reminder per calendar — Google's per-calendar defaults live server-side.
|
||||||
|
So both the global default *and* the per-calendar override are **app-side
|
||||||
|
preferences**, applied by us at event-insert time. We inherit nothing from the
|
||||||
|
synced calendar.
|
||||||
|
|
||||||
|
- **Storage (DataStore):**
|
||||||
|
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
|
||||||
|
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
|
||||||
|
reminders are expressed as minutes before midnight / day-before-at-time, not
|
||||||
|
minutes before a start instant — they need their own value).
|
||||||
|
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
|
||||||
|
**absent key = inherit global**, explicit `null` = "no reminder for this
|
||||||
|
calendar". (Same for an all-day override map if we want per-calendar all-day.)
|
||||||
|
- **Apply on create:** a fresh event prefills its reminders list from
|
||||||
|
override-or-global for the preselected calendar. Changing the calendar in the
|
||||||
|
form re-applies the *new* calendar's default **only if the user hasn't manually
|
||||||
|
edited the reminders** — track a dirty flag, mirroring the per-event-color
|
||||||
|
reset pattern (v2.4).
|
||||||
|
- **Edit semantics:** defaults apply to **new events only**; never rewrite
|
||||||
|
reminders on existing events on open or on calendar-switch-during-edit.
|
||||||
|
- **Settings UI (Notifications sub-page):**
|
||||||
|
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
|
||||||
|
/ 1 h / 1 day / custom), plus the separate all-day default.
|
||||||
|
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
|
||||||
|
or a Notifications subsection), each opening the same OptionCard with a
|
||||||
|
leading **"Use global default"** option.
|
||||||
|
|
||||||
|
### B. Delivery reliability (exact alarms + battery)
|
||||||
|
|
||||||
|
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
|
||||||
|
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
|
||||||
|
it directly undermines the feature's premise, so it rides in here.
|
||||||
|
|
||||||
|
- **Exact alarm — decision first:** trust the provider broadcast, or
|
||||||
|
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
|
||||||
|
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
|
||||||
|
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
|
||||||
|
fallback for API 31–32 (user-revocable → settings deep-link prompt).
|
||||||
|
- **Battery-optimization exemption:** a *soft, optional* prompt via
|
||||||
|
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
|
||||||
|
auto-grant intent), honest copy: "Android may delay reminders to save battery;
|
||||||
|
exempt Calendula for on-time delivery." Shown once after the existing
|
||||||
|
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
|
||||||
|
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
|
||||||
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
|
are notorious for suppressing it).
|
||||||
|
|
||||||
|
### Open decisions (resolve before building)
|
||||||
|
|
||||||
|
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
||||||
|
(reliability vs simplicity + battery cost).
|
||||||
|
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
||||||
|
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
||||||
|
a list inside the Notifications sub-page).
|
||||||
|
|
||||||
|
### Later (round two)
|
||||||
|
|
||||||
- Snooze + dismiss actions on the notification (snooze needs an
|
- Snooze + dismiss actions on the notification (snooze needs an
|
||||||
exact-alarm / WorkManager decision)
|
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||||
- Settings default reminder applied to new events
|
|
||||||
|
|
||||||
## Sharing & interop
|
## Sharing & interop
|
||||||
|
|
||||||
- Share event as .ics + open/receive .ics into a prefilled create form
|
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
|
||||||
(front-runs the import below)
|
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
|
||||||
- ICS file import (drag-and-drop) *(was v3.0, optional)*
|
event prefills the create form, many events opens a bulk import (dedup by UID)
|
||||||
|
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
|
||||||
|
flow above (single → form, many → bulk import)
|
||||||
|
|
||||||
## Platform & launchers
|
## Platform & launchers
|
||||||
|
|
||||||
- Home-screen widget *(was v3.0)*
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
||||||
|
optional quick-settings tile still open
|
||||||
|
|
||||||
|
## Quality & reliability
|
||||||
|
|
||||||
|
- **Accessibility pass** — TalkBack content descriptions across all screens,
|
||||||
|
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
|
||||||
|
F-Droid app; nothing tracks it yet.
|
||||||
|
- **Reminder delivery reliability** — exact alarms + battery-optimization
|
||||||
|
exemption; specced in the "Reminders — defaults & delivery reliability" slice
|
||||||
|
above (Tier 4 #9).
|
||||||
|
|
||||||
## Locations & People *(go/no-go, captured 2026-06-11)*
|
## Locations & People *(go/no-go, captured 2026-06-11)*
|
||||||
|
|
||||||
@@ -326,9 +467,57 @@ in detail yet:
|
|||||||
trade-off is an explicit go/no-go decision before any work starts.
|
trade-off is an explicit go/no-go decision before any work starts.
|
||||||
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
||||||
if the picker proves clunky.
|
if the picker proves clunky.
|
||||||
- **Attendee editing / invites from contacts** — own milestone; writing
|
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
|
||||||
`Attendees` rows touches sync-adapter invitation behavior (Google vs
|
Tier 5 #12, high-importance; the no-permission typed-email path is not an
|
||||||
DAVx5 differ).
|
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
|
||||||
|
|
||||||
|
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
|
||||||
|
|
||||||
|
The last read-only gap in the event model: attendees are read & shown on the
|
||||||
|
detail screen (since v0.6) but the form can't write them. Make guests editable.
|
||||||
|
|
||||||
|
- **Read side already done:** `Attendee` domain model + status/relationship/type
|
||||||
|
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
|
||||||
|
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
|
||||||
|
there.
|
||||||
|
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
|
||||||
|
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
|
||||||
|
inline grouped list — each guest a tonal card (avatar, name/email, tappable
|
||||||
|
Required/Optional role chip, remove), the trailing card an inline email field
|
||||||
|
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
|
||||||
|
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
|
||||||
|
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
|
||||||
|
required/optional type updates; organizer/resource/no-email rows untouched).
|
||||||
|
Reminders restyled to the same grouped-list pattern in passing. Needs only the
|
||||||
|
existing `WRITE_CALENDAR` — no new permission.
|
||||||
|
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
|
||||||
|
field is email-only (the quick path); the add row also has a **"from contacts"
|
||||||
|
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI` →
|
||||||
|
the system Contacts app returns a URI to the picked email row, queried for
|
||||||
|
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
|
||||||
|
email and name in one tap. The result Intent grants temporary read access, so
|
||||||
|
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
|
||||||
|
mechanism as the location address picker above. Self/organizer rows stay
|
||||||
|
non-editable.
|
||||||
|
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
|
||||||
|
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
|
||||||
|
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
|
||||||
|
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
|
||||||
|
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
|
||||||
|
and Google decides (third-party attendee writes are historically unreliable
|
||||||
|
there). Editing is allowed on **any writable calendar** — not gated to local.
|
||||||
|
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
|
||||||
|
decision): on a synced calendar show "Calendula doesn't send invitations —
|
||||||
|
your calendar account may email guests when it syncs"; on a local calendar
|
||||||
|
show "Stored on this device. No one is notified."
|
||||||
|
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
|
||||||
|
state to coax a send — it writes the guest list faithfully and leaves
|
||||||
|
scheduling entirely to the backend.
|
||||||
|
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
|
||||||
|
still no INTERNET) was considered and **deferred** — revisit only if users ask
|
||||||
|
to notify guests explicitly.
|
||||||
|
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
|
||||||
|
resource booking — all carry server round-trips or richer sync semantics.
|
||||||
|
|
||||||
## Consciously rejected
|
## Consciously rejected
|
||||||
|
|
||||||
@@ -336,3 +525,5 @@ in detail yet:
|
|||||||
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
||||||
form already covers fast entry)
|
form already covers fast entry)
|
||||||
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
||||||
|
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
|
||||||
|
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-17*
|
*Last updated: 2026-06-22*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
|
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
||||||
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
|
||||||
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
|
||||||
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
interop) arc is now done or in flight:
|
||||||
the calendar manager and the navigation drawer) both shipped 2026-06-16. The
|
- v2.4.0 per-event colors (2026-06-17)
|
||||||
backlog is now organised by theme in `ROADMAP.md`.
|
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
|
||||||
|
event" launcher shortcut (2026-06-17)
|
||||||
|
- v2.6.0 default reminders (global + per-calendar override, all-day default,
|
||||||
|
battery-exemption row) + system per-app language (2026-06-18)
|
||||||
|
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
|
||||||
|
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
|
||||||
|
(2026-06-18)
|
||||||
|
- v2.7.1–v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
|
||||||
|
|
||||||
|
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
|
||||||
|
holds crash reports via the public Codeberg tracker (MR !27) + reminder
|
||||||
|
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
|
||||||
|
release-cut.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -118,11 +130,48 @@ backlog is now organised by theme in `ROADMAP.md`.
|
|||||||
sync" warning on the picker and in Settings. Color writes flow through
|
sync" warning on the picker and in Settings. Color writes flow through
|
||||||
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
||||||
|
|
||||||
|
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
|
||||||
|
jump-to-date drawer date picker, two home-screen widgets (scrolling
|
||||||
|
"Upcoming" agenda + month grid), and a "New event" launcher long-press
|
||||||
|
shortcut
|
||||||
|
|
||||||
|
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
|
||||||
|
separate all-day default + per-calendar override (timed), applied on create
|
||||||
|
with dirty-flag handling; three pickers + override list in Settings →
|
||||||
|
Notifications; battery-optimisation exemption row (status + system deep-link,
|
||||||
|
no new permission). Plus system per-app language (Android 13+) and an
|
||||||
|
immediate-effect fix for the in-app language picker
|
||||||
|
|
||||||
|
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
|
||||||
|
`.ics` from the detail screen; back up local calendars (Settings → Calendars
|
||||||
|
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
|
||||||
|
create form, many events open a bulk import into a chosen calendar (dedup by
|
||||||
|
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
|
||||||
|
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
|
||||||
|
a widget R8 keep-rule crash fix
|
||||||
|
|
||||||
|
- [x] v2.7.1–v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
|
||||||
|
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
|
||||||
|
F-Droid reproducible-build cleanups for the official repo
|
||||||
|
|
||||||
|
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
|
||||||
|
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
|
||||||
|
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
|
||||||
2. Decide the "Locations & People" and "remote calendar create/edit"
|
merge-driven pipeline) once on-device review signs off
|
||||||
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
|
||||||
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
importance, opened 2026-06-22). Attendees are already read & shown on the
|
||||||
agenda view (strategic, backs a future widget). Full ranked sequence in
|
detail screen since v0.6; the gap is the write side — make guests editable
|
||||||
`ROADMAP.md` → "Near-term sequence".
|
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
|
||||||
|
persisted by diffing `CalendarContract.Attendees`. No new permission for the
|
||||||
|
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
|
||||||
|
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
|
||||||
|
copy ("your account may email guests when it syncs" on synced calendars, "no one
|
||||||
|
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
|
||||||
|
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
|
||||||
|
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
|
||||||
|
numbers in month, full-text search, accessibility pass). Drag-and-drop
|
||||||
|
rescheduling is **rejected**.
|
||||||
|
|||||||
141
CHANGELOG.md
141
CHANGELOG.md
@@ -5,7 +5,146 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [2.8.0] — 2026-06-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Find events fast. A search button in the top bar of every calendar view opens
|
||||||
|
a search box — type a couple of letters and matching events (by title,
|
||||||
|
location or description) appear, soonest first with past events below. Tap a
|
||||||
|
result to open it. Search covers your whole calendar, not just what's on
|
||||||
|
screen, and skips calendars you've hidden. A recurring event shows its next
|
||||||
|
occurrence rather than the date the series first started.
|
||||||
|
- A current-time line in the day and week views. A thin coloured line marks the
|
||||||
|
present moment across today's column, so you can see at a glance where you are
|
||||||
|
in the day. It updates every minute and only appears when today is in view.
|
||||||
|
- Add and remove event guests. The create/edit form now has a Guests section:
|
||||||
|
add people by email or pick them from your contacts, mark each as required or
|
||||||
|
optional, and remove them. Calendula never sends invitations itself (it has no
|
||||||
|
internet access) — it only records the guests; if the event lives on a synced
|
||||||
|
account, that account may email them when it syncs, and on a local calendar no
|
||||||
|
one is notified. The form tells you which applies. Picking a guest from
|
||||||
|
contacts needs no contacts permission.
|
||||||
|
- Pick a location from your contacts. A contacts button beside the location
|
||||||
|
field drops a contact's address straight into an event — handy for a meeting
|
||||||
|
at someone's home or office. Like the guest picker, it needs no contacts
|
||||||
|
permission.
|
||||||
|
- A "New event" Quick Settings tile. Add it to your quick settings to jump
|
||||||
|
straight into the new-event form from anywhere. Settings → New event has a
|
||||||
|
one-tap button to add the tile (Android 13+); on older versions you can add it
|
||||||
|
from the quick-settings editor.
|
||||||
|
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
|
||||||
|
reminder; snooze hides it and brings it back after a delay you pick in
|
||||||
|
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
|
||||||
|
system won't re-post a reminder on its own, so Calendula schedules an exact
|
||||||
|
alarm to bring a snoozed one back on time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event details now show each guest's email beneath their name, instead of only
|
||||||
|
when no name is available.
|
||||||
|
- Crash and problem reports now open on the project's public Codeberg tracker,
|
||||||
|
where anyone can register and file an issue. Nothing is sent automatically —
|
||||||
|
you still review the report and submit it yourself in the browser.
|
||||||
|
|
||||||
|
## [2.7.5] — 2026-06-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Further build cleanup for the official F-Droid repository: stopped embedding
|
||||||
|
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
|
||||||
|
scanner rejects as an extra signing block. No functional or visible changes —
|
||||||
|
the same app as 2.7.4, just without that Play-oriented metadata blob.
|
||||||
|
|
||||||
|
## [2.7.4] — 2026-06-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Build cleanup that lets Calendula ship in the official F-Droid repository:
|
||||||
|
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
|
||||||
|
reproducible build process disallows. No functional or visible changes — this
|
||||||
|
is the same app as 2.7.3.
|
||||||
|
|
||||||
|
## [2.7.3] — 2026-06-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Home-screen widgets no longer get stuck on a loading spinner in the published
|
||||||
|
(F-Droid) release build. They render via Android's background-work system, and
|
||||||
|
release optimisation (R8) was stripping a helper class it loads by name, so the
|
||||||
|
render job never ran. Added the missing keep rule — widgets now load normally.
|
||||||
|
|
||||||
|
## [2.7.2] — 2026-06-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Crash reporting you control. If Calendula closes unexpectedly, it now captures
|
||||||
|
a technical report and, on the next launch, offers to send it as an issue on
|
||||||
|
the project's tracker. Nothing is uploaded automatically — the report stays on
|
||||||
|
your device until you choose to share it, it contains no personal data or
|
||||||
|
calendar content (only the app, Android and device versions plus the stack
|
||||||
|
trace), and you see the full text before sending. There's also a "Report a
|
||||||
|
problem" entry in Settings, and if the app ever fails to start repeatedly, a
|
||||||
|
minimal recovery screen still lets you send the report.
|
||||||
|
|
||||||
|
## [2.7.1] — 2026-06-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed the app crashing immediately on launch whenever calendar access hadn't
|
||||||
|
been granted yet (a fresh install, or after revoking the permission). The app
|
||||||
|
set up its live calendar-change listener before the permission screen could
|
||||||
|
appear, which newer Android versions reject outright — so the app died before
|
||||||
|
you could grant access. The listener now waits for the permission and attaches
|
||||||
|
itself the moment it's granted.
|
||||||
|
|
||||||
|
## [2.7.0] — 2026-06-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Share a single event as an `.ics` file from the event detail screen — hands a
|
||||||
|
standard calendar file to any app via the system share sheet.
|
||||||
|
- Back up your local (device-only) calendars: Settings → Calendars → Export as
|
||||||
|
`.ics` file writes every event of your on-device calendars to a file you
|
||||||
|
choose. Local calendars aren't synced anywhere, so this is their only backup.
|
||||||
|
- Open or share an `.ics` file into Calendula: a single event opens the create
|
||||||
|
form prefilled for review, while a file with many events (e.g. a backup) opens
|
||||||
|
a bulk import — pick a calendar and import them all. Re-importing a backup
|
||||||
|
won't create duplicates (events are matched by their unique identifier), and
|
||||||
|
anything Calendula can't represent (changed recurring occurrences, guest
|
||||||
|
lists) is reported rather than silently dropped.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- All-day events that cover a single day (e.g. a birthday) no longer show up on
|
||||||
|
the following day as well — in the day, week and month views or on the event
|
||||||
|
detail screen. The extra day came from interpreting the all-day date range in
|
||||||
|
the device's time zone instead of UTC.
|
||||||
|
- Fixed the app crashing immediately on every launch in the optimized release
|
||||||
|
build: release code-shrinking (R8) was stripping a database class the
|
||||||
|
home-screen widget framework needs, so the app died at startup before showing
|
||||||
|
anything. Added the missing keep rule.
|
||||||
|
|
||||||
|
## [2.6.0] — 2026-06-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- App language can now be set from Android's system per-app language settings
|
||||||
|
(Android 13+), in addition to the in-app picker in Settings — and the app is
|
||||||
|
set up so further languages can be added by community translators
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Changing the app language in Settings now takes effect immediately; the
|
||||||
|
picker previously had no effect
|
||||||
|
|
||||||
|
## [2.5.0] — 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Home-screen widgets (two of them): an "Upcoming" agenda widget — a scrolling
|
||||||
|
list of the next month of events grouped under day headers, with refresh and
|
||||||
|
"New event" buttons — and a month-grid widget showing the full month with
|
||||||
|
today highlighted, connected multi-day event bars, and prev/next/today
|
||||||
|
navigation. Both reuse the in-app grouping and layout so they match the app
|
||||||
|
exactly, respect your hidden-calendar choices, and refresh automatically when
|
||||||
|
the calendar changes or the day rolls over. Tapping a day opens that day;
|
||||||
|
tapping an event opens its details
|
||||||
|
- App shortcut: long-press the Calendula icon for a "New event" action that
|
||||||
|
jumps straight into the create-event form
|
||||||
|
- Agenda view — a fourth top-level view alongside Month/Week/Day: a
|
||||||
|
forward-looking list of upcoming events grouped under "Today"/"Tomorrow"/date
|
||||||
|
headers, reachable from the view switcher
|
||||||
|
- Jump to date — a "Jump to date" row in the navigation drawer opens a date
|
||||||
|
picker and moves the active view (Month/Week/Day/Agenda) to the chosen day
|
||||||
|
|
||||||
## [2.4.0] — 2026-06-17
|
## [2.4.0] — 2026-06-17
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
|
<img src="fastlane/metadata/android/en-US/images/icon.png" width="112" alt="Calendula icon">
|
||||||
|
|
||||||
<h1>Calendula</h1>
|
<h1>Calendula</h1>
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
// The git tag is the single source of truth for released builds: at
|
// These committed values ARE the source of truth for a release: merging
|
||||||
// release time .gitea/workflows/release.yaml derives both fields from
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 20400
|
versionCode = 20800
|
||||||
versionName = "2.4.0"
|
versionName = "2.8.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,11 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
// Keep release builds reproducible for F-Droid: don't let AGP embed
|
||||||
|
// build-environment git metadata (META-INF/version-control-info.textproto),
|
||||||
|
// whose `revision`/path content varies by build machine and is the only
|
||||||
|
// thing that otherwise differs from a clean from-source rebuild.
|
||||||
|
vcsInfo { include = false }
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -61,6 +66,21 @@ android {
|
|||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
}
|
}
|
||||||
|
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
|
||||||
|
// and resource shrinking, but debug-signed and given its own applicationId
|
||||||
|
// suffix so it installs alongside both the production app (signed with the
|
||||||
|
// real key) and the debug build. Used to smoke-test a release candidate on
|
||||||
|
// a real device before tagging — R8-only breakage and first-run/permission
|
||||||
|
// states don't surface in the unminified debug build, nor on a device that
|
||||||
|
// already holds the permission. Never published. See docs/RELEASING.md.
|
||||||
|
create("releaseTest") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
applicationIdSuffix = ".releasetest"
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
matchingFallbacks += "release"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -72,12 +92,31 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||||
|
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
|
||||||
|
// "extra signing block" — so leaving it in blocks publishing to the official
|
||||||
|
// repo. It lives in the signing block, not the zip entries, so disabling it
|
||||||
|
// doesn't change the build output (reproducibility is unaffected).
|
||||||
|
dependenciesInfo {
|
||||||
|
includeInApk = false
|
||||||
|
includeInBundle = false
|
||||||
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Community translations are expected to be partial — a missing string
|
||||||
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
|
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||||
|
// check_translations.py guards the same invariants with clearer,
|
||||||
|
// translator-facing messages.
|
||||||
|
informational += "MissingTranslation"
|
||||||
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests {
|
unitTests {
|
||||||
all { it.useJUnitPlatform() }
|
all { it.useJUnitPlatform() }
|
||||||
@@ -113,6 +152,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
implementation(libs.androidx.glance.appwidget)
|
||||||
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
|||||||
22
app/proguard-rules.pro
vendored
22
app/proguard-rules.pro
vendored
@@ -4,3 +4,25 @@
|
|||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|
||||||
|
# Room database implementations (pulled in transitively via
|
||||||
|
# androidx.glance:glance-appwidget → androidx.work → androidx.room).
|
||||||
|
# The widgets rely on Glance, whose WorkManager backend stores state in a Room
|
||||||
|
# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
|
||||||
|
# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
|
||||||
|
# so Room's reflective instantiation throws InstantiationException and the app
|
||||||
|
# crashes at startup with "Failed to create an instance of ...WorkDatabase".
|
||||||
|
# Keep the generated Room database implementations fully intact.
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||||
|
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||||
|
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||||
|
# through a WorkManager worker (androidx.glance.session.SessionWorker) whose
|
||||||
|
# default merger is androidx.work.OverwritingInputMerger. Under R8 full mode
|
||||||
|
# (AGP 9 default) that unused no-arg constructor was stripped, so WorkManager
|
||||||
|
# threw "OverwritingInputMerger has no zero argument constructor", the
|
||||||
|
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||||
|
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||||
|
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||||
|
|||||||
@@ -5,6 +5,24 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
|
delayed by Doze). Used only to launch that dialog; falls back to the
|
||||||
|
battery-optimisation list if the OS declines the direct intent.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
|
||||||
|
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
|
||||||
|
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 31–32 (user-revocable,
|
||||||
|
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
|
||||||
|
-->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
<!-- 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
|
||||||
@@ -25,6 +43,7 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Calendula"
|
android:theme="@style/Theme.Calendula"
|
||||||
@@ -38,8 +57,51 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||||
|
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Receive a .ics shared from another app. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||||
|
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||||
|
graph and Compose content. Not exported: launched only by us. -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.crash.CrashReportActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
|
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
|
||||||
|
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
|
||||||
|
bind it; the action mirrors the launcher "New event" shortcut. -->
|
||||||
|
<service
|
||||||
|
android:name=".qs.NewEventTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_qs_new_event"
|
||||||
|
android:label="@string/qs_tile_new_event_label"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
@@ -54,6 +116,71 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
|
||||||
|
re-show alarm. Not exported: only our own notification buttons and
|
||||||
|
AlarmManager PendingIntents target it. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.ReminderActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
|
android:label="@string/widget_agenda_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_agenda" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.month.MonthWidgetReceiver"
|
||||||
|
android:label="@string/widget_month_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_month" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
|
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||||
|
and the system broadcasts the date/time ones at midnight / clock
|
||||||
|
changes so "today" highlighting rolls over. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||||
|
<data
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:scheme="content" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Hands .ics files we stage in the cache to other apps via a content
|
||||||
|
Uri (single-event share). Authority tracks applicationId so the
|
||||||
|
debug suffix doesn't break getUriForFile. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||||
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class CalendulaApp : Application()
|
class CalendulaApp : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
|
// respecting, on-device; the user submits the report by hand).
|
||||||
|
CrashReporter.install(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,37 +2,74 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
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.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// 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.
|
||||||
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
|
// A navigation a home-screen widget asked for (open a date / start a
|
||||||
|
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||||
|
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||||
|
|
||||||
|
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
|
||||||
|
// by CalendarHost's import flow.
|
||||||
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
|
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// If the app keeps crashing as it starts, the main UI can't be trusted
|
||||||
|
// to come up — route to the standalone report screen instead of
|
||||||
|
// re-entering the crashing graph.
|
||||||
|
if (CrashReporter.isCrashLoop(this)) {
|
||||||
|
startActivity(
|
||||||
|
Intent(this, CrashReportActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
|
requestedNav = intent.navRequestOrNull()
|
||||||
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
@@ -51,14 +88,67 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
requestedDetailKey = requestedDetailKey,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
|
widgetNavRequest = requestedNav,
|
||||||
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = { requestedImportUri = null },
|
||||||
)
|
)
|
||||||
|
pendingCrashReport?.let { report ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this@MainActivity, report)
|
||||||
|
CrashReporter.clearReport(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
// Keep the report (Settings can still reach it); just
|
||||||
|
// stop it popping on every launch.
|
||||||
|
CrashReporter.dismissPrompt(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||||
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
|
* app's own `calendula://` deep-links never match.
|
||||||
|
*/
|
||||||
|
private fun Intent.importUriOrNull(): Uri? {
|
||||||
|
val uri = when (action) {
|
||||||
|
Intent.ACTION_VIEW -> data
|
||||||
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||||
|
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||||
|
// can't carry typed extras, so the action alone signals create-on-today.
|
||||||
|
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||||
|
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||||
|
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||||
|
getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||||
|
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Intent.detailKeyOrNull(): LongArray? {
|
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||||
@@ -75,6 +165,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
|
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||||
|
|
||||||
|
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||||
|
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||||
|
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intent opening the detail screen of one occurrence (reminder
|
* Intent opening the detail screen of one occurrence (reminder
|
||||||
@@ -93,5 +189,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
putExtra(EXTRA_END_MILLIS, endMillis)
|
putExtra(EXTRA_END_MILLIS, endMillis)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the day view anchored on [date] (home-screen widgets). */
|
||||||
|
fun openDateIntent(context: Context, date: LocalDate): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://date/$date".toUri()
|
||||||
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||||
|
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://create/$date".toUri()
|
||||||
|
putExtra(EXTRA_CREATE, true)
|
||||||
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
|
* speaks (whole days before the event — "1 day before") and the **raw**
|
||||||
|
* `CalendarContract.Reminders.MINUTES` offset the provider stores.
|
||||||
|
*
|
||||||
|
* Calendula schedules no alarms itself: the provider fires a reminder at
|
||||||
|
* `DTSTART − MINUTES` (the Etar model). An all-day event's DTSTART is **UTC
|
||||||
|
* midnight** (see [EventWriteTimes]), so a raw `MINUTES = 1440` ("1 day") lands
|
||||||
|
* on UTC-midnight of the previous day — 02:00 local in CEST, not the morning.
|
||||||
|
*
|
||||||
|
* To fire at a chosen wall-clock time we encode that time *into* the offset:
|
||||||
|
* `MINUTES = UTC-midnight(startDate) − (localInstant of [timeOfDayMinutes] on the
|
||||||
|
* day [semanticMinutes] before)`. The single fixed offset can only be tuned for
|
||||||
|
* the event's own date, so a recurring all-day series or a post-creation
|
||||||
|
* timezone change drifts the fire time by the offset delta (±1h across DST) —
|
||||||
|
* an inherent limit of the provider model, shared by Etar.
|
||||||
|
*/
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider `MINUTES` for an all-day reminder set [semanticMinutes] before the
|
||||||
|
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||||
|
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||||
|
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||||
|
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
||||||
|
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
||||||
|
* through unchanged.
|
||||||
|
*/
|
||||||
|
internal fun toProviderAllDayMinutes(
|
||||||
|
semanticMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
timeOfDayMinutes: Int,
|
||||||
|
): Int {
|
||||||
|
if (semanticMinutes < 0) return semanticMinutes
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||||
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
|
.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||||
|
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||||
|
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||||
|
* after DTSTART) folds to day 0.
|
||||||
|
*/
|
||||||
|
internal fun fromProviderAllDayMinutes(
|
||||||
|
rawMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Int {
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||||
|
.atZone(zone).toLocalDate()
|
||||||
|
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -11,17 +13,24 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -38,6 +47,14 @@ interface CalendarDataSource {
|
|||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off events whose title, description or location contains
|
||||||
|
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
||||||
|
* Events table directly so the search is unbounded in time; exception rows
|
||||||
|
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
||||||
|
*/
|
||||||
|
fun searchEvents(query: String): List<EventInstance>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||||
@@ -47,6 +64,26 @@ interface CalendarDataSource {
|
|||||||
*/
|
*/
|
||||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
|
* rows are excluded (see [EventExportProjection]).
|
||||||
|
*/
|
||||||
|
fun exportableEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
|
* dedup an `.ics` import so re-importing a backup doesn't double events.
|
||||||
|
*/
|
||||||
|
fun existingUids(calendarId: Long): Set<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
|
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||||
|
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||||
|
*/
|
||||||
|
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||||
@@ -60,24 +97,40 @@ 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)
|
||||||
|
|
||||||
/** Insert a new event; returns the new `Events._ID`. */
|
/**
|
||||||
fun insertEvent(form: EventForm): Long
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
|
* should fire at — encoded into each all-day reminder's provider offset
|
||||||
|
* (ignored for timed events).
|
||||||
|
*/
|
||||||
|
fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing event (for recurring events: the whole series) to
|
* Update an existing event (for recurring events: the whole series) to
|
||||||
* match [updated]. [original] is the form as it was prefilled from the
|
* match [updated]. [original] is the form as it was prefilled from the
|
||||||
* event, so only fields the user actually changed are written and the
|
* event, so only fields the user actually changed are written and the
|
||||||
* reminder rows can be diffed instead of wiped.
|
* reminder rows can be diffed instead of wiped.
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
* row's `Events._ID`.
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a recurring event from the occurrence at [beginMillis] onwards
|
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||||
@@ -92,6 +145,7 @@ interface CalendarDataSource {
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,14 +174,52 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
) : CalendarDataSource {
|
) : CalendarDataSource {
|
||||||
|
|
||||||
private val resolver: ContentResolver get() = context.contentResolver
|
private val resolver: ContentResolver get() = context.contentResolver
|
||||||
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
|
||||||
|
|
||||||
override fun calendars(): List<CalendarSource> = resolver.query(
|
// All access to these two collections is guarded by [observerLock] because
|
||||||
CalendarContract.Calendars.CONTENT_URI,
|
// listeners are registered on the main thread (repository init, via ViewModel
|
||||||
CalendarProjection.COLUMNS,
|
// creation) while [ensureObserversRegistered] runs on the IO dispatcher.
|
||||||
null, null,
|
private val observerLock = Any()
|
||||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
||||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
private val registeredObservers = mutableSetOf<ContentObserver>()
|
||||||
|
|
||||||
|
private fun hasCalendarPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach any not-yet-registered observers to the provider, but only once the
|
||||||
|
* calendar permission is held. Registering a ContentObserver on the calendar
|
||||||
|
* provider without that permission throws SecurityException on newer Android
|
||||||
|
* (it used to silently no-op), which crashed the app at launch — the repo
|
||||||
|
* registers its observer eagerly, before the permission gate. Called from the
|
||||||
|
* observed reads ([calendars]/[instances]) so the observer re-attaches the
|
||||||
|
* first time a screen queries after the permission is granted.
|
||||||
|
*/
|
||||||
|
private fun ensureObserversRegistered() {
|
||||||
|
if (!hasCalendarPermission()) return
|
||||||
|
synchronized(observerLock) {
|
||||||
|
if (registeredObservers.size == observers.size) return
|
||||||
|
observers.values.forEach(::registerObserverLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerObserverLocked(obs: ContentObserver) {
|
||||||
|
if (obs in registeredObservers || !hasCalendarPermission()) return
|
||||||
|
runCatching {
|
||||||
|
resolver.registerContentObserver(CalendarContract.CONTENT_URI, true, obs)
|
||||||
|
}.onSuccess { registeredObservers += obs }
|
||||||
|
.onFailure { Log.w(TAG, "Calendar observer registration skipped", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun calendars(): List<CalendarSource> {
|
||||||
|
ensureObserversRegistered()
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
|
CalendarProjection.COLUMNS,
|
||||||
|
null, null,
|
||||||
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
|
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-row writes must address the provider as a sync adapter and name
|
* Calendar-row writes must address the provider as a sync adapter and name
|
||||||
@@ -162,7 +254,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
putDescription(description)
|
putDescription(description)
|
||||||
}
|
}
|
||||||
val uri = resolver.insert(localCalendarsUri(), values)
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
?: throw WriteFailedException("create local calendar '$name'")
|
// No calendar name in the message — it can reach a crash report.
|
||||||
|
?: throw WriteFailedException("create local calendar")
|
||||||
return ContentUris.parseId(uri)
|
return ContentUris.parseId(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +293,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
|
ensureObserversRegistered()
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
ContentUris.appendId(this, beginMillis)
|
ContentUris.appendId(this, beginMillis)
|
||||||
ContentUris.appendId(this, endMillis)
|
ContentUris.appendId(this, endMillis)
|
||||||
@@ -212,6 +306,89 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun searchEvents(query: String): List<EventInstance> {
|
||||||
|
ensureObserversRegistered()
|
||||||
|
val trimmed = query.trim()
|
||||||
|
if (trimmed.isEmpty()) return emptyList()
|
||||||
|
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
||||||
|
// itself instead of acting as a wildcard.
|
||||||
|
val escaped = trimmed
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_")
|
||||||
|
val like = "%$escaped%"
|
||||||
|
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||||
|
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||||
|
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||||
|
val selection = "($match) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
SearchProjection.COLUMNS,
|
||||||
|
selection,
|
||||||
|
arrayOf(like, like, like),
|
||||||
|
CalendarContract.Events.DTSTART + " DESC",
|
||||||
|
)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
val out = ArrayList<EventInstance>(c.count)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val base = reader.toSearchResult() ?: continue
|
||||||
|
// A recurring master's DTSTART is the series start; show its
|
||||||
|
// nearest occurrence instead so the date is the one the user
|
||||||
|
// actually cares about (and sorting reflects it).
|
||||||
|
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
|
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
||||||
|
out += if (recurring) {
|
||||||
|
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||||
|
base.copy(
|
||||||
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
)
|
||||||
|
} ?: base
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||||
|
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||||
|
* within the same window behind. Null when neither exists (e.g. a series
|
||||||
|
* that only starts further out than the window) — the caller then keeps the
|
||||||
|
* series-start DTSTART. Returns (begin, end) epoch millis.
|
||||||
|
*/
|
||||||
|
private fun nearestOccurrenceMillis(eventId: Long): Pair<Long, Long>? {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
|
||||||
|
?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun occurrenceInWindow(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
soonestFirst: Boolean,
|
||||||
|
): Pair<Long, Long>? {
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
|
ContentUris.appendId(this, beginMillis)
|
||||||
|
ContentUris.appendId(this, endMillis)
|
||||||
|
}.build()
|
||||||
|
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
|
||||||
|
"${CalendarContract.Instances.EVENT_ID} = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
order,
|
||||||
|
)?.use { c ->
|
||||||
|
if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
@@ -247,6 +424,112 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun exportableEvents(): List<IcsEvent> {
|
||||||
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
|
// already have a backup (their server). Map id → display name for the
|
||||||
|
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||||
|
val names = calendars()
|
||||||
|
.filter { it.isLocal && it.canModifyContents }
|
||||||
|
.associate { it.id to it.displayName }
|
||||||
|
if (names.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val idList = names.keys.joinToString(",")
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
EventExportProjection.COLUMNS,
|
||||||
|
// Skip soft-deleted rows and exception rows (modified occurrences /
|
||||||
|
// cancellations) — v1 exports masters + one-offs only.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
|
||||||
|
null,
|
||||||
|
CalendarContract.Events.DTSTART + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll {
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
||||||
|
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||||
|
reader.toIcsEvent(
|
||||||
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
|
calendarName = names[calendarId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||||
|
arrayOf(calendarId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
|
} ?: emptySet()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||||
|
val startMillis = event.start.toEpochMillis()
|
||||||
|
val endMillis = event.end.toEpochMillis()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
|
// only when the source event carried none.
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, startMillis)
|
||||||
|
if (event.recurrenceRule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// timed rows keep the event's own zone.
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
|
val eventId = ContentUris.parseId(uri)
|
||||||
|
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||||
|
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
@@ -265,13 +548,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
private data class CalendarAccount(val name: String, val type: String)
|
private data class CalendarAccount(val name: String, val type: String)
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
/**
|
||||||
|
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
|
||||||
|
* all-day reminder is shifted to fire at [allDayReminderTimeMinutes] local
|
||||||
|
* (see [toProviderAllDayMinutes]); a timed reminder is its lead time as-is.
|
||||||
|
*/
|
||||||
|
private fun providerReminderMinutes(
|
||||||
|
form: EventForm,
|
||||||
|
minutes: Int,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Int = if (form.isAllDay) {
|
||||||
|
toProviderAllDayMinutes(
|
||||||
|
semanticMinutes = minutes,
|
||||||
|
startDate = form.start.date.toJavaLocalDate(),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [form]'s reminders as the distinct raw provider offsets to store. */
|
||||||
|
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
|
||||||
|
form.reminders
|
||||||
|
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(
|
put(
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
)
|
)
|
||||||
|
// A globally-unique UID so a later .ics backup/restore can identify
|
||||||
|
// the event and not duplicate it on re-import (the provider leaves
|
||||||
|
// this null for events it didn't sync). Older rows without one fall
|
||||||
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
|
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
@@ -303,20 +617,30 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
// Best effort (spec §8): the event exists at this point — a reminder
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
// that fails to attach is logged, not surfaced as a failed create.
|
||||||
form.reminders.distinct().forEach { minutes ->
|
encodedReminders(form, allDayReminderTimeMinutes)
|
||||||
val reminder = ContentValues().apply {
|
.forEach { minutes ->
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
// Guests are best-effort like reminders: a row that fails to attach is
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
// logged, not surfaced as a failed create. Calendula never sends an
|
||||||
}
|
// invitation — it only writes the rows; the backend decides delivery.
|
||||||
}
|
insertAttendees(eventId, form.attendees)
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
override fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
) {
|
||||||
val values = buildEventUpdateValues(
|
val values = buildEventUpdateValues(
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
@@ -332,13 +656,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||||
}
|
}
|
||||||
// Untouched reminder sets are left alone so unrelated edits can't
|
// Untouched reminder sets are left alone so unrelated edits can't
|
||||||
// disturb provider rows the form never knew about.
|
// disturb provider rows the form never knew about. The diff is on the
|
||||||
|
// form's semantic minutes; reconcile works in encoded provider minutes.
|
||||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
reconcileReminders(eventId, updated.reminders)
|
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||||
|
}
|
||||||
|
// Same untouched-set guard for guests: only reconcile when the form's
|
||||||
|
// attendees actually changed, so unrelated edits never disturb the
|
||||||
|
// organizer/resource rows the form doesn't model.
|
||||||
|
if (updated.attendees.toSet() != original.attendees.toSet()) {
|
||||||
|
reconcileAttendees(eventId, updated.attendees)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
override fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -352,7 +688,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val exceptionId = ContentUris.parseId(uri)
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
// Whether the provider copied the parent's reminder rows is its
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
// business — reconciling against the actual rows handles both ways.
|
// business — reconciling against the actual rows handles both ways.
|
||||||
reconcileReminders(exceptionId, form.reminders)
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
reconcileAttendees(exceptionId, form.attendees)
|
||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,16 +698,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// From the first occurrence on (or with no rule to split) this is
|
// From the first occurrence on (or with no rule to split) this is
|
||||||
// just a series update.
|
// just a series update.
|
||||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
updateEvent(eventId, original, updated)
|
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
// Insert the new series first: if it fails, the original is untouched.
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
val newEventId = insertEvent(updated)
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
truncateSeries(eventId, row, beginMillis)
|
truncateSeries(eventId, row, beginMillis)
|
||||||
return newEventId
|
return newEventId
|
||||||
}
|
}
|
||||||
@@ -456,9 +794,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the event's reminder rows match [targetMinutes]: rows with other
|
* Make the event's reminder rows match [targetMinutes] — the raw provider
|
||||||
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
* offsets to store (already encoded via [encodedReminders], so all-day shifts
|
||||||
* (like insertEvent). Rows whose minutes survive keep their method.
|
* are baked in and the diff matches the stored rows). Rows with other offsets
|
||||||
|
* are deleted, missing ones inserted as best-effort ALERTs (like insertEvent).
|
||||||
|
* Rows whose minutes survive keep their method.
|
||||||
*/
|
*/
|
||||||
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||||
val target = targetMinutes.toSet()
|
val target = targetMinutes.toSet()
|
||||||
@@ -483,6 +823,135 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
||||||
|
private fun EventAttendee.key(): String = email.trim().lowercase()
|
||||||
|
|
||||||
|
/** Distinct guests by email; blanks dropped (we key and write on the address). */
|
||||||
|
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
return filter { it.email.isNotBlank() && seen.add(it.key()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
|
||||||
|
ContentValues().apply {
|
||||||
|
put(CalendarContract.Attendees.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
|
||||||
|
attendee.name.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
|
||||||
|
// A plain guest — never the organizer (we don't fabricate scheduling
|
||||||
|
// identity; the backend owns that). Invited = awaiting their reply.
|
||||||
|
put(
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
|
||||||
|
put(
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventAttendee.providerType(): Int = if (optional) {
|
||||||
|
CalendarContract.Attendees.TYPE_OPTIONAL
|
||||||
|
} else {
|
||||||
|
CalendarContract.Attendees.TYPE_REQUIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort attendee inserts (like reminders); emails are never logged. */
|
||||||
|
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||||
|
attendees.distinctByEmail().forEach { attendee ->
|
||||||
|
if (resolver.insert(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
attendeeValues(eventId, attendee),
|
||||||
|
) == null
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AttendeeRow(
|
||||||
|
val id: Long,
|
||||||
|
val email: String,
|
||||||
|
val relationship: Int,
|
||||||
|
val type: Int,
|
||||||
|
) {
|
||||||
|
val key: String get() = email.trim().lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Attendees._ID,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||||
|
),
|
||||||
|
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
AttendeeRow(
|
||||||
|
id = c.getLong(0),
|
||||||
|
email = c.getString(1).orEmpty(),
|
||||||
|
relationship = c.getInt(2),
|
||||||
|
type = c.getInt(3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the event's *editable* guest rows match [attendees]. Only rows we
|
||||||
|
* own — has an email, not the organizer, not a resource — are reconciled;
|
||||||
|
* everything else the backend put there is left untouched. Matched rows keep
|
||||||
|
* their response status (only the required/optional type is updated if it
|
||||||
|
* changed); dropped rows are deleted; new guests inserted. Calendula sends
|
||||||
|
* nothing — the backend decides if anyone is notified on sync.
|
||||||
|
*/
|
||||||
|
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||||
|
val target = attendees.distinctByEmail()
|
||||||
|
val targetByKey = target.associateBy { it.key() }
|
||||||
|
val editable = queryAttendeeRows(eventId).filter {
|
||||||
|
it.email.isNotBlank() &&
|
||||||
|
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
|
||||||
|
it.type != CalendarContract.Attendees.TYPE_RESOURCE
|
||||||
|
}
|
||||||
|
val editableKeys = editable.map { it.key }.toSet()
|
||||||
|
editable.forEach { row ->
|
||||||
|
val want = targetByKey[row.key]
|
||||||
|
if (want == null) {
|
||||||
|
resolver.delete(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
CalendarContract.Attendees._ID + " = ?",
|
||||||
|
arrayOf(row.id.toString()),
|
||||||
|
)
|
||||||
|
} else if (row.type != want.providerType()) {
|
||||||
|
resolver.update(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
ContentValues().apply {
|
||||||
|
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
|
||||||
|
},
|
||||||
|
CalendarContract.Attendees._ID + " = ?",
|
||||||
|
arrayOf(row.id.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
|
||||||
|
if (resolver.insert(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
attendeeValues(eventId, attendee),
|
||||||
|
) == null
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||||
ContentValues().also { cv ->
|
ContentValues().also { cv ->
|
||||||
forEach { (column, value) ->
|
forEach { (column, value) ->
|
||||||
@@ -491,7 +960,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
is String -> cv.put(column, value)
|
is String -> cv.put(column, value)
|
||||||
is Long -> cv.put(column, value)
|
is Long -> cv.put(column, value)
|
||||||
is Int -> cv.put(column, value)
|
is Int -> cv.put(column, value)
|
||||||
else -> error("Unsupported value for $column: $value")
|
// Only the type, never the value — a cell value can be event content.
|
||||||
|
else -> error("Unsupported value type for column '$column': ${value::class.simpleName}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,16 +994,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
listener()
|
listener()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observers[listener] = obs
|
synchronized(observerLock) {
|
||||||
resolver.registerContentObserver(
|
observers[listener] = obs
|
||||||
CalendarContract.CONTENT_URI,
|
// Attach now if we already hold the permission; otherwise it stays
|
||||||
/* notifyForDescendants = */ true,
|
// pending and re-attaches on the first read after the grant.
|
||||||
obs,
|
registerObserverLocked(obs)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unregisterChangeListener(listener: () -> Unit) {
|
override fun unregisterChangeListener(listener: () -> Unit) {
|
||||||
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
|
synchronized(observerLock) {
|
||||||
|
observers.remove(listener)?.let { obs ->
|
||||||
|
if (registeredObservers.remove(obs)) resolver.unregisterContentObserver(obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
|
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
|
||||||
@@ -572,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* together (by account) in the filter sheet and calendar manager.
|
* together (by account) in the filter sheet and calendar manager.
|
||||||
*/
|
*/
|
||||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead/behind a search looks for a recurring event's nearest
|
||||||
|
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||||
|
* next fires beyond it falls back to its series-start date.
|
||||||
|
*/
|
||||||
|
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -13,6 +16,13 @@ interface CalendarRepository {
|
|||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events whose title, description or location contains [query], with hidden
|
||||||
|
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||||
|
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||||
|
*/
|
||||||
|
suspend fun searchEvents(query: String): List<EventInstance>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette a calendar's account publishes; empty when it
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||||
@@ -28,6 +38,19 @@ 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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
*/
|
||||||
|
suspend fun exportEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||||
|
* UID already exists in the target are skipped (idempotent restore); the
|
||||||
|
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
|
||||||
|
*/
|
||||||
|
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||||
|
|
||||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
suspend fun createEvent(form: EventForm): Long
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
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.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
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.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.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
@@ -28,9 +32,14 @@ import javax.inject.Singleton
|
|||||||
class CalendarRepositoryImpl @Inject constructor(
|
class CalendarRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: CalendarDataSource,
|
private val dataSource: CalendarDataSource,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : CalendarRepository {
|
) : CalendarRepository {
|
||||||
|
|
||||||
|
/** The configured wall-clock fire time for all-day reminders, read per write. */
|
||||||
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
@@ -71,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
|
val hidden = prefs.hiddenCalendarIds.first()
|
||||||
|
dataSource.searchEvents(query)
|
||||||
|
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||||
|
|
||||||
@@ -92,8 +108,30 @@ 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 exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||||
|
|
||||||
|
override suspend fun importEvents(
|
||||||
|
targetCalendarId: Long,
|
||||||
|
events: List<ParsedIcsEvent>,
|
||||||
|
): IcsImportSummary = withContext(io) {
|
||||||
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
|
var imported = 0
|
||||||
|
var skipped = 0
|
||||||
|
for (event in events) {
|
||||||
|
// A known UID means the event is already in this calendar — skip,
|
||||||
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
|
if (event.uid != null && event.uid in existing) {
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
dataSource.insertEvent(form)
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEvent(
|
override suspend fun updateEvent(
|
||||||
@@ -101,7 +139,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
dataSource.updateEvent(eventId, original, updated)
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
@@ -113,7 +151,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateOccurrence(eventId, beginMillis, form)
|
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEventFromOccurrence(
|
override suspend fun updateEventFromOccurrence(
|
||||||
@@ -122,7 +160,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
|
dataSource.updateEventFromOccurrence(
|
||||||
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
|
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
instanceId = eventId,
|
instanceId = eventId,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
@@ -65,11 +69,23 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
title = title,
|
title = title,
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
|
isAllDay = isAllDay,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// All-day reminders are stored as a wall-clock-shifted offset (see
|
||||||
|
// AllDayReminderEncoding); decode back to the whole-day lead time the form
|
||||||
|
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
||||||
|
// event's date is its UTC date.
|
||||||
|
val displayReminders = if (isAllDay) {
|
||||||
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||||
|
} else {
|
||||||
|
reminders
|
||||||
|
}
|
||||||
|
|
||||||
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
@@ -84,7 +100,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
reminders = reminders,
|
reminders = displayReminders,
|
||||||
status = status,
|
status = status,
|
||||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
// default these mappers already return — no isNull guard needed.
|
// default these mappers already return — no isNull guard needed.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
|
* for backup. [reminderMinutes] are the row's raw provider reminder offsets and
|
||||||
|
* [calendarName] the display name of its calendar (emitted as
|
||||||
|
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||||
|
* MapColumnReader.
|
||||||
|
*/
|
||||||
|
internal fun ColumnReader.toIcsEvent(
|
||||||
|
reminderMinutes: List<Int>,
|
||||||
|
calendarName: String?,
|
||||||
|
): IcsEvent {
|
||||||
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
|
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||||
|
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||||
|
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||||
|
val end = when {
|
||||||
|
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
|
||||||
|
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
|
||||||
|
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
||||||
|
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||||
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||||
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
|
?: "UTC",
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
|
reminderMinutes = reminderMinutes,
|
||||||
|
status = status,
|
||||||
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -97,6 +97,86 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||||
|
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||||
|
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||||
|
* occurrence and cancelled-exception rows are filtered out by the query
|
||||||
|
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||||
|
* exported yet — a documented v1 limit (import skips them too).
|
||||||
|
*/
|
||||||
|
internal object EventExportProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_UID = 1
|
||||||
|
const val IDX_TITLE = 2
|
||||||
|
const val IDX_DTSTART = 3
|
||||||
|
const val IDX_DTEND = 4
|
||||||
|
const val IDX_DURATION = 5
|
||||||
|
const val IDX_ALL_DAY = 6
|
||||||
|
const val IDX_EVENT_TIMEZONE = 7
|
||||||
|
const val IDX_RRULE = 8
|
||||||
|
const val IDX_LOCATION = 9
|
||||||
|
const val IDX_DESCRIPTION = 10
|
||||||
|
const val IDX_STATUS = 11
|
||||||
|
const val IDX_AVAILABILITY = 12
|
||||||
|
const val IDX_CALENDAR_ID = 13
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off Events rows matched by a full-text search. Like
|
||||||
|
* [EventExportProjection] it reads the Events table directly (so the search is
|
||||||
|
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
|
||||||
|
* Colour folds the calendar fallback like [InstanceProjection].
|
||||||
|
*/
|
||||||
|
internal object SearchProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.EVENT_COLOR,
|
||||||
|
CalendarContract.Events.CALENDAR_COLOR,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
// Recurrence markers: a non-empty RRULE or RDATE means the result should
|
||||||
|
// display its nearest occurrence, not the series-start DTSTART.
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.RDATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_CALENDAR_ID = 1
|
||||||
|
const val IDX_TITLE = 2
|
||||||
|
const val IDX_DTSTART = 3
|
||||||
|
const val IDX_DTEND = 4
|
||||||
|
const val IDX_DURATION = 5
|
||||||
|
const val IDX_ALL_DAY = 6
|
||||||
|
const val IDX_EVENT_COLOR = 7
|
||||||
|
const val IDX_CALENDAR_COLOR = 8
|
||||||
|
const val IDX_LOCATION = 9
|
||||||
|
const val IDX_RRULE = 10
|
||||||
|
const val IDX_RDATE = 11
|
||||||
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
|
||||||
|
* Instances query this reads the series master, so there is no instance id (the
|
||||||
|
* event id stands in as the list key) and recurring rows carry DURATION instead
|
||||||
|
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||||
|
*/
|
||||||
|
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||||
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
|
if (dtStart < 0L) return null
|
||||||
|
val end = when {
|
||||||
|
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||||
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
|
}.coerceAtLeast(dtStart)
|
||||||
|
|
||||||
|
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||||
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
|
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||||
|
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||||
|
} else {
|
||||||
|
getInt(SearchProjection.IDX_EVENT_COLOR)
|
||||||
|
}
|
||||||
|
val eventId = getLong(SearchProjection.IDX_ID)
|
||||||
|
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = eventId,
|
||||||
|
eventId = eventId,
|
||||||
|
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
|
||||||
|
title = title,
|
||||||
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||||
|
color = color,
|
||||||
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.crash
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
|
import java.io.File
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||||
|
* exception it writes a self-contained report to the app's private storage and
|
||||||
|
* then chains to the platform's default handler, so the process still dies
|
||||||
|
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||||
|
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||||
|
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||||
|
*
|
||||||
|
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||||
|
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||||
|
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||||
|
* is always shown the full text before it leaves the device.
|
||||||
|
*/
|
||||||
|
object CrashReporter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||||
|
* also catches crashes during startup. The handler swallows nothing — it
|
||||||
|
* persists, then delegates to the previously-registered handler.
|
||||||
|
*/
|
||||||
|
fun install(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
// Capturing must never mask the original crash, so guard every step.
|
||||||
|
runCatching {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||||
|
recordCrashTime(appContext, now)
|
||||||
|
}
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The persisted report from the last crash, or null if there is none. */
|
||||||
|
fun pendingReport(context: Context): String? {
|
||||||
|
val file = reportFile(context)
|
||||||
|
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to surface the report unprompted (on the next launch): a report
|
||||||
|
* exists and the user hasn't already waved this one away. Settings reaches
|
||||||
|
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||||
|
* auto-prompt — it doesn't discard the report.
|
||||||
|
*/
|
||||||
|
fun shouldPrompt(context: Context): Boolean =
|
||||||
|
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||||
|
|
||||||
|
/** Stop auto-prompting for the current report without discarding it. */
|
||||||
|
fun dismissPrompt(context: Context) {
|
||||||
|
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||||
|
fun clearReport(context: Context) {
|
||||||
|
runCatching { reportFile(context).delete() }
|
||||||
|
runCatching { dismissedFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app appears to be in a startup crash-loop: at least
|
||||||
|
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||||
|
* can't be trusted to start, so the caller routes straight to the standalone
|
||||||
|
* report screen instead of re-entering the crashing graph.
|
||||||
|
*/
|
||||||
|
fun isCrashLoop(context: Context): Boolean {
|
||||||
|
val times = readCrashTimes(context)
|
||||||
|
if (times.size < LOOP_THRESHOLD) return false
|
||||||
|
val recent = times.sortedDescending()
|
||||||
|
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the app as having started successfully, resetting the loop counter so
|
||||||
|
* an ordinary single crash much later never trips loop detection. The
|
||||||
|
* pending report itself is kept — only the timing trail is cleared.
|
||||||
|
*/
|
||||||
|
fun markHealthy(context: Context) {
|
||||||
|
runCatching { timesFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- persistence -------------------------------------------------------
|
||||||
|
|
||||||
|
private fun writeReport(context: Context, report: String) {
|
||||||
|
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||||
|
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||||
|
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||||
|
runCatching { dismissedFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||||
|
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||||
|
timesFile(context).apply { parentFile?.mkdirs() }
|
||||||
|
.writeText(kept.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readCrashTimes(context: Context): List<Long> {
|
||||||
|
val file = timesFile(context)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||||
|
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||||
|
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||||
|
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||||
|
|
||||||
|
private const val CRASH_DIR = "crash"
|
||||||
|
private const val REPORT_FILE = "last_crash.txt"
|
||||||
|
private const val TIMES_FILE = "crash_times.txt"
|
||||||
|
private const val DISMISSED_FILE = "dismissed"
|
||||||
|
private const val MAX_TIMES = 5
|
||||||
|
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||||
|
private const val LOOP_THRESHOLD = 2
|
||||||
|
private const val LOOP_WINDOW_MS = 10_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||||
|
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||||
|
*/
|
||||||
|
data class CrashContext(
|
||||||
|
val appVersionName: String,
|
||||||
|
val appVersionCode: Long,
|
||||||
|
val sdkInt: Int,
|
||||||
|
val androidRelease: String,
|
||||||
|
val manufacturer: String,
|
||||||
|
val model: String,
|
||||||
|
val locale: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(context: Context): CrashContext {
|
||||||
|
val pkg = runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
}.getOrNull()
|
||||||
|
return CrashContext(
|
||||||
|
appVersionName = pkg?.versionName ?: "?",
|
||||||
|
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||||
|
sdkInt = Build.VERSION.SDK_INT,
|
||||||
|
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||||
|
manufacturer = Build.MANUFACTURER ?: "?",
|
||||||
|
model = Build.MODEL ?: "?",
|
||||||
|
locale = Locale.getDefault().toLanguageTag(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||||
|
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||||
|
* tested. The leading marker doubles as the file's sanity check in
|
||||||
|
* [CrashReporter.pendingReport].
|
||||||
|
*/
|
||||||
|
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||||
|
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||||
|
val time = runCatching {
|
||||||
|
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||||
|
}.getOrDefault(nowMillis.toString())
|
||||||
|
return buildString {
|
||||||
|
appendLine("Calendula crash report")
|
||||||
|
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||||
|
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||||
|
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||||
|
appendLine("Locale: ${ctx.locale}")
|
||||||
|
appendLine("Time: $time")
|
||||||
|
appendLine()
|
||||||
|
appendLine("Stack trace:")
|
||||||
|
append(trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Android IO edge of `.ics` export: writes a serialised calendar to a
|
||||||
|
* SAF document (whole-calendar backup) or stages it in a cache file behind a
|
||||||
|
* `FileProvider` content Uri (single-event share). The serialisation itself is
|
||||||
|
* the pure `domain.ics.IcsWriter`; this class only moves bytes.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsExporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */
|
||||||
|
fun writeDocument(uri: Uri, content: String) {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
|
out.write(content.toByteArray(Charsets.UTF_8))
|
||||||
|
// Only the scheme — the full Uri can embed the user's chosen filename.
|
||||||
|
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage [content] in a private cache file and return a shareable content
|
||||||
|
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||||
|
* the receiving app. The authority mirrors the manifest's `FileProvider`.
|
||||||
|
*/
|
||||||
|
fun stageShareFile(fileName: String, content: String): Uri {
|
||||||
|
val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() }
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
file.writeText(content, Charsets.UTF_8)
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SHARE_DIR = "shared_ics"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android IO edge of `.ics` import: reads the text of a received/opened
|
||||||
|
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
|
||||||
|
* pulls bytes off the ContentResolver. Returns null on any read failure.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsImporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
fun readText(uri: Uri): String? = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -127,6 +128,111 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes before start) prefilled on new
|
||||||
|
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||||
|
* as the factory default so existing users aren't surprised by reminders they
|
||||||
|
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||||
|
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||||
|
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time prefilled on new **all-day** events, in
|
||||||
|
* minutes before the start of the day. All-day events want day-scale lead
|
||||||
|
* times ("1 day before"), so they have their own default rather than reusing
|
||||||
|
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||||
|
* to all-day events — they always use this global value.
|
||||||
|
*/
|
||||||
|
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock time, as minutes from local midnight, at which **all-day**
|
||||||
|
* reminders fire. All-day events live at UTC midnight, so a raw "1 day
|
||||||
|
* before" would fire at an off hour (02:00 local in CEST); this time is
|
||||||
|
* encoded into the provider offset so the reminder lands at, e.g., 09:00 the
|
||||||
|
* day before instead. Global for every all-day reminder; default 09:00.
|
||||||
|
* Stored/clamped to a valid 0..1439 minute-of-day.
|
||||||
|
*/
|
||||||
|
val allDayReminderTimeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME)
|
||||||
|
.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the notification "Snooze" action defers a reminder, in minutes.
|
||||||
|
* Tapping Snooze cancels the notification and re-posts it after this delay
|
||||||
|
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
|
||||||
|
* clamped to at least 1.
|
||||||
|
*/
|
||||||
|
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
|
* timed default for its new events: a `null` value means "no reminder", an int
|
||||||
|
* means that lead time. A calendar **absent** from the map inherits the global
|
||||||
|
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||||
|
* no-reminder override. (All-day events ignore this and use
|
||||||
|
* [defaultAllDayReminderMinutes].)
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day**
|
||||||
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
|
* inherit the global all-day default; present null = no reminder).
|
||||||
|
*/
|
||||||
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current =
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -143,10 +249,93 @@ class SettingsPrefs @Inject constructor(
|
|||||||
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 =
|
||||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
|
internal val DEFAULT_ALLDAY_REMINDER_KEY =
|
||||||
|
stringPreferencesKey("default_allday_reminder_minutes")
|
||||||
|
internal val ALLDAY_REMINDER_TIME_KEY =
|
||||||
|
intPreferencesKey("allday_reminder_time_minutes")
|
||||||
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||||
|
/** Default snooze delay for the notification "Snooze" action. */
|
||||||
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||||
|
sealed interface CalendarReminderOverride {
|
||||||
|
/** No override — the calendar uses the global default. */
|
||||||
|
data object Inherit : CalendarReminderOverride
|
||||||
|
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||||
|
data object None : CalendarReminderOverride
|
||||||
|
/** A specific lead time in minutes before the event start. */
|
||||||
|
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead time to prefill on a new event: the matching per-calendar override
|
||||||
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
|
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||||
|
* it can be unit-tested.
|
||||||
|
*/
|
||||||
|
fun resolveDefaultReminder(
|
||||||
|
timedGlobal: Int?,
|
||||||
|
allDayGlobal: Int?,
|
||||||
|
timedOverrides: Map<Long, Int?>,
|
||||||
|
allDayOverrides: Map<Long, Int?>,
|
||||||
|
calendarId: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
): Int? {
|
||||||
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
|
overrides[calendarId]
|
||||||
|
} else {
|
||||||
|
global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||||
|
private fun MutableMap<Long, Int?>.applyOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
|
CalendarReminderOverride.None -> put(calendarId, null)
|
||||||
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NONE = "none"
|
||||||
|
private const val ENTRY_SEP = ";"
|
||||||
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
|
||||||
|
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||||
|
null, "", NONE -> null
|
||||||
|
else -> toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||||
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
id to value
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||||
|
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||||
|
* the internal re-show when a snooze elapses. All three are app-internal
|
||||||
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
|
* alarm), so the receiver is not exported.
|
||||||
|
*
|
||||||
|
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||||
|
* already fired, so nothing re-posts it.
|
||||||
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
|
* it after the user's snooze delay.
|
||||||
|
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||||
|
* or dismiss it again.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var scheduler: ReminderSnoozeScheduler
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val alert = alertFrom(intent) ?: return
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_DISMISS -> notifier.cancel(alert)
|
||||||
|
|
||||||
|
ACTION_SNOOZE -> {
|
||||||
|
// Cancel now so the notification doesn't linger until the alarm;
|
||||||
|
// the snooze delay read is the only async work.
|
||||||
|
notifier.cancel(alert)
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||||
|
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||||
|
scheduler.schedule(alert, triggerAt)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_SHOW -> {
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||||
|
notifier.post(alert)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
|
||||||
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
|
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||||
|
|
||||||
|
private const val EXTRA_ALERT_ID = "alert_id"
|
||||||
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
|
private const val EXTRA_BEGIN = "begin"
|
||||||
|
private const val EXTRA_END = "end"
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_LOCATION = "location"
|
||||||
|
private const val EXTRA_ALL_DAY = "all_day"
|
||||||
|
|
||||||
|
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||||
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
|
this.action = action
|
||||||
|
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||||
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
|
putExtra(EXTRA_END, alert.endMillis)
|
||||||
|
putExtra(EXTRA_TITLE, alert.title)
|
||||||
|
putExtra(EXTRA_LOCATION, alert.location)
|
||||||
|
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable request code per (alert, action) so the three PendingIntents
|
||||||
|
* of one notification stay distinct and don't clobber each other.
|
||||||
|
*/
|
||||||
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
|
val actionOffset = when (action) {
|
||||||
|
ACTION_SNOOZE -> 1
|
||||||
|
ACTION_DISMISS -> 2
|
||||||
|
ACTION_SHOW -> 3
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
return alert.alertId.toInt() * 8 + actionOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
|
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||||
|
return ReminderAlert(
|
||||||
|
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||||
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||||
|
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||||
|
location = intent.getStringExtra(EXTRA_LOCATION),
|
||||||
|
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(detailIntent(alert))
|
.setContentIntent(detailIntent(alert))
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification_snooze,
|
||||||
|
context.getString(R.string.reminder_action_snooze),
|
||||||
|
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification_dismiss,
|
||||||
|
context.getString(R.string.reminder_action_dismiss),
|
||||||
|
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
@@ -71,6 +81,19 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
|
fun cancel(alert: ReminderAlert) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ReminderActionReceiver.requestCode(alert, action),
|
||||||
|
ReminderActionReceiver.intent(context, action, alert),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||||
|
*
|
||||||
|
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||||
|
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||||
|
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||||
|
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||||
|
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||||
|
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderSnoozeScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
|
||||||
|
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
if (canScheduleExact(alarmManager)) {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||||
|
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
||||||
|
}
|
||||||
@@ -41,6 +41,27 @@ data class EventForm(
|
|||||||
*/
|
*/
|
||||||
val colorKey: String? = null,
|
val colorKey: String? = null,
|
||||||
val color: Int? = null,
|
val color: Int? = null,
|
||||||
|
/**
|
||||||
|
* Guests the user has added/kept on the event. Calendula only writes these
|
||||||
|
* `Attendees` rows — it has no INTERNET and never sends an invitation
|
||||||
|
* itself; whether a guest is notified is decided downstream by the
|
||||||
|
* calendar's backend (local: no one; CalDAV/Google: the server/account).
|
||||||
|
* Read-only rows the form doesn't model (the organizer, resources) are
|
||||||
|
* preserved by the data layer, not carried here.
|
||||||
|
*/
|
||||||
|
val attendees: List<EventAttendee> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||||
|
* write on), an optional display [name], and whether they're [optional] rather
|
||||||
|
* than required. Response status and the organizer/resource distinction are not
|
||||||
|
* user-editable, so they aren't modelled here.
|
||||||
|
*/
|
||||||
|
data class EventAttendee(
|
||||||
|
val email: String,
|
||||||
|
val name: String = "",
|
||||||
|
val optional: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +76,7 @@ enum class EventFormField {
|
|||||||
Availability,
|
Availability,
|
||||||
Visibility,
|
Visibility,
|
||||||
Color,
|
Color,
|
||||||
|
Attendees,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EventFormProblem {
|
enum class EventFormProblem {
|
||||||
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
// calendar's colour.
|
// calendar's colour.
|
||||||
colorKey = eventColorKey,
|
colorKey = eventColorKey,
|
||||||
color = eventColor,
|
color = eventColor,
|
||||||
|
// Only editable guests ride in the form: drop the organizer and
|
||||||
|
// resource rows (not user-editable) and any without an email (we key
|
||||||
|
// edits and dedup on the address). The data layer preserves the rows we
|
||||||
|
// don't carry here, so they're never clobbered on save.
|
||||||
|
attendees = attendees
|
||||||
|
.filter {
|
||||||
|
it.relationship != AttendeeRelationship.Organizer &&
|
||||||
|
it.type != AttendeeType.Resource
|
||||||
|
}
|
||||||
|
.mapNotNull { a ->
|
||||||
|
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||||
|
EventAttendee(
|
||||||
|
email = email,
|
||||||
|
name = a.name,
|
||||||
|
optional = a.type == AttendeeType.Optional,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
* while the form was open. The raw row times ride along because
|
* while the form was open. The raw row times ride along because
|
||||||
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||||
* re-deriving with the same occurrence would mask an externally moved
|
* re-deriving with the same occurrence would mask an externally moved
|
||||||
* event. Not covered (the form can't write them, and the dirty-checked
|
* event. Guests are covered (the form writes editable attendees), so an
|
||||||
* write can't clobber them): attendees, status, the user's own response,
|
* external attendee change now also trips the conflict check. Still not
|
||||||
* reminder methods, and a recurring event's duration.
|
* covered: status, the user's own response, reminder methods, the
|
||||||
|
* organizer/resource rows, and a recurring event's duration.
|
||||||
*/
|
*/
|
||||||
data class EditSnapshot(
|
data class EditSnapshot(
|
||||||
val form: EventForm,
|
val form: EventForm,
|
||||||
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
|||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
|
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the [IcsEvent] for sharing a single event. We export the event the user
|
||||||
|
* is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one
|
||||||
|
* occurrence, so "share this event" should hand off exactly that instance, not
|
||||||
|
* a whole series anchored to a possibly-different DTSTART. Reminders are the
|
||||||
|
* already-decoded semantic lead times the detail screen holds.
|
||||||
|
*/
|
||||||
|
fun EventDetail.toShareIcsEvent(): IcsEvent {
|
||||||
|
val startMillis = instance.start.toEpochMilliseconds()
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis),
|
||||||
|
summary = instance.title,
|
||||||
|
start = instance.start,
|
||||||
|
end = instance.end,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC",
|
||||||
|
recurrenceRule = null,
|
||||||
|
location = instance.location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.map { it.minutes },
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
// Android's calendar provider (and Calendula's own writes) use the non-standard
|
||||||
|
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
|
||||||
|
// leading T. Matched first; anything else falls through to the general grammar.
|
||||||
|
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
|
||||||
|
private val DURATION_GENERAL =
|
||||||
|
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `-PT15M`, `P1W`, …),
|
||||||
|
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
|
||||||
|
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
|
||||||
|
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
|
||||||
|
*/
|
||||||
|
fun parseRfc2445DurationMillis(duration: String?): Long {
|
||||||
|
if (duration.isNullOrBlank()) return 0L
|
||||||
|
val s = duration.trim()
|
||||||
|
|
||||||
|
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
|
||||||
|
val unitSeconds = when (m.groupValues[3]) {
|
||||||
|
"W" -> 7L * 24 * 60 * 60
|
||||||
|
"D" -> 24L * 60 * 60
|
||||||
|
else -> 1L // S
|
||||||
|
}
|
||||||
|
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
|
||||||
|
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
|
||||||
|
val days = m.groupValues[3].toLongOrNull() ?: 0L
|
||||||
|
val hours = m.groupValues[4].toLongOrNull() ?: 0L
|
||||||
|
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
|
||||||
|
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
|
||||||
|
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
|
||||||
|
return m.signum() * totalSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
|
||||||
|
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single event ready to be serialised to a `VEVENT`, decoupled from the
|
||||||
|
* provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are
|
||||||
|
* absolute instants; [isAllDay] and [recurrenceRule] decide how they are
|
||||||
|
* rendered (see [IcsWriter]'s timezone rule).
|
||||||
|
*/
|
||||||
|
data class IcsEvent(
|
||||||
|
/** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */
|
||||||
|
val uid: String,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
/** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
/** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */
|
||||||
|
val zoneId: String,
|
||||||
|
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UID to export for a provider event. A row that already carries a UID
|
||||||
|
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
|
||||||
|
* the event id and its DTSTART so the same legacy event yields the same UID
|
||||||
|
* across repeated backups — which keeps a later restore from duplicating it.
|
||||||
|
*/
|
||||||
|
fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String =
|
||||||
|
existingUid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: "$eventId-$dtStartMillis@calendula"
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
|
* how the writer encoded them.
|
||||||
|
*/
|
||||||
|
data class ParsedIcsEvent(
|
||||||
|
val uid: String?,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val zoneId: String,
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
|
enum class IcsParseWarning {
|
||||||
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
|
ModifiedOccurrenceSkipped,
|
||||||
|
|
||||||
|
/** A `VEVENT` with no parseable `DTSTART`. */
|
||||||
|
EventWithoutStartSkipped,
|
||||||
|
|
||||||
|
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
|
||||||
|
AttendeesIgnored,
|
||||||
|
|
||||||
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
|
UnknownTimezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IcsParseResult(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||||
|
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
|
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
|
||||||
|
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
|
||||||
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
|
*/
|
||||||
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
|
fun parse(text: String): IcsParseResult {
|
||||||
|
val lines = unfoldLines(text)
|
||||||
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
|
var calendarName: String? = null
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when {
|
||||||
|
line.isBegin("VEVENT") -> {
|
||||||
|
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||||
|
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||||
|
?.let(events::add)
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
line.isBegin("VTIMEZONE") -> {
|
||||||
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
|
}
|
||||||
|
line.name == "X-WR-CALNAME" -> {
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsParseResult(events, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVevent(
|
||||||
|
body: List<String>,
|
||||||
|
fileCalendarName: String?,
|
||||||
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
|
): ParsedIcsEvent? {
|
||||||
|
var uid: String? = null
|
||||||
|
var summary = ""
|
||||||
|
var dtStart: IcsDateTime? = null
|
||||||
|
var dtEnd: IcsDateTime? = null
|
||||||
|
var duration: String? = null
|
||||||
|
var rrule: String? = null
|
||||||
|
var location: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
var status = EventStatus.Confirmed
|
||||||
|
var availability = Availability.Busy
|
||||||
|
var calendarName = fileCalendarName
|
||||||
|
val reminders = mutableListOf<Int>()
|
||||||
|
var skipAsOverride = false
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < body.size) {
|
||||||
|
val line = parseContentLine(body[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when (line.name) {
|
||||||
|
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
|
||||||
|
val end = indexOfEnd(body, i + 1, "VALARM")
|
||||||
|
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
|
"DURATION" -> duration = line.value.trim()
|
||||||
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
|
"TRANSP" -> availability =
|
||||||
|
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
|
||||||
|
else Availability.Busy
|
||||||
|
"RECURRENCE-ID" -> skipAsOverride = true
|
||||||
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
|
"X-CALENDULA-CALENDAR" ->
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipAsOverride) {
|
||||||
|
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val start = dtStart ?: run {
|
||||||
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val end = dtEnd
|
||||||
|
?: duration?.let {
|
||||||
|
start.copy(
|
||||||
|
instant = Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: start
|
||||||
|
return ParsedIcsEvent(
|
||||||
|
uid = uid,
|
||||||
|
summary = summary,
|
||||||
|
start = start.instant,
|
||||||
|
end = end.instant,
|
||||||
|
isAllDay = start.isAllDay,
|
||||||
|
zoneId = start.zoneId,
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.distinct(),
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||||
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
|
val trigger = body.asSequence()
|
||||||
|
.mapNotNull { parseContentLine(it) }
|
||||||
|
.firstOrNull { it.name == "TRIGGER" }
|
||||||
|
?: return null
|
||||||
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
|
// Negative = before start (the normal case) → positive lead minutes.
|
||||||
|
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
|
val raw = line.value.trim()
|
||||||
|
val isDate = line.params["VALUE"].equals("DATE", true) ||
|
||||||
|
(raw.length == 8 && !raw.contains('T'))
|
||||||
|
if (isDate) {
|
||||||
|
val date = parseBasicDate(raw) ?: return null
|
||||||
|
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
|
||||||
|
}
|
||||||
|
val isUtc = raw.endsWith("Z")
|
||||||
|
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
|
||||||
|
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||||
|
|
||||||
|
val tzid = line.params["TZID"]
|
||||||
|
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
||||||
|
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||||
|
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||||
|
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && line.name == "END" &&
|
||||||
|
line.value.trim().equals(component, true)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return lines.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
||||||
|
"TENTATIVE" -> EventStatus.Tentative
|
||||||
|
"CANCELLED" -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||||
|
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||||
|
val date = LocalDate(
|
||||||
|
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
||||||
|
val time = LocalTime(
|
||||||
|
s.substring(9, 11).toInt(),
|
||||||
|
s.substring(11, 13).toInt(),
|
||||||
|
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the
|
||||||
|
* escaping and folding rules can be tested in isolation. Pure Kotlin — no
|
||||||
|
* Android, no time handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** iCalendar mandates CRLF line breaks, not the platform separator. */
|
||||||
|
const val ICS_CRLF: String = "\r\n"
|
||||||
|
|
||||||
|
/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */
|
||||||
|
private const val MAX_OCTETS = 75
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11:
|
||||||
|
* backslash, semicolon and comma are escaped, newlines become the literal `\n`.
|
||||||
|
* Backslash is handled first so it doesn't double-escape the others' markers.
|
||||||
|
*/
|
||||||
|
fun escapeText(value: String): String = buildString(value.length) {
|
||||||
|
for (ch in value) {
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
';' -> append("\\;")
|
||||||
|
',' -> append("\\,")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a single content line to ≤75 octets per physical line, inserting
|
||||||
|
* `CRLF + space` between segments (the space is part of the 75-octet budget of
|
||||||
|
* the continuation line, so its content caps at 74). Folding counts UTF-8
|
||||||
|
* octets, never splitting a multi-byte character across a boundary.
|
||||||
|
*/
|
||||||
|
fun foldLine(line: String): String {
|
||||||
|
if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line
|
||||||
|
val out = StringBuilder()
|
||||||
|
var octetsThisLine = 0
|
||||||
|
var first = true
|
||||||
|
var i = 0
|
||||||
|
while (i < line.length) {
|
||||||
|
val cp = line.codePointAt(i)
|
||||||
|
val width = Character.charCount(cp)
|
||||||
|
val piece = line.substring(i, i + width)
|
||||||
|
val pieceOctets = piece.toByteArray(Charsets.UTF_8).size
|
||||||
|
// Continuation lines spend one octet on the leading space.
|
||||||
|
val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1
|
||||||
|
if (octetsThisLine + pieceOctets > budget) {
|
||||||
|
out.append(ICS_CRLF).append(' ')
|
||||||
|
octetsThisLine = 0
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
out.append(piece)
|
||||||
|
octetsThisLine += pieceOctets
|
||||||
|
i += width
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
|
||||||
|
* `\,`. A backslash before any other character is dropped, keeping the
|
||||||
|
* character (lenient — foreign files escape liberally).
|
||||||
|
*/
|
||||||
|
fun unescapeText(value: String): String = buildString(value.length) {
|
||||||
|
var i = 0
|
||||||
|
while (i < value.length) {
|
||||||
|
val c = value[i]
|
||||||
|
if (c == '\\' && i + 1 < value.length) {
|
||||||
|
when (val next = value[i + 1]) {
|
||||||
|
'n', 'N' -> append('\n')
|
||||||
|
else -> append(next) // \\, \;, \, and any other escaped char
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [foldLine] across a whole document: split into physical lines on
|
||||||
|
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
|
||||||
|
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
|
||||||
|
*/
|
||||||
|
fun unfoldLines(text: String): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (physical in text.split("\r\n", "\n", "\r")) {
|
||||||
|
if (physical.isEmpty()) continue
|
||||||
|
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
|
||||||
|
if (isContinuation && out.isNotEmpty()) {
|
||||||
|
out[out.lastIndex] = out.last() + physical.substring(1)
|
||||||
|
} else {
|
||||||
|
out.add(physical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One unfolded content line split into its property name, parameters and value:
|
||||||
|
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
|
||||||
|
* `Lunch`. The value is everything after the first colon that isn't inside a
|
||||||
|
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
|
||||||
|
* Returns null for a line with no colon.
|
||||||
|
*/
|
||||||
|
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
|
||||||
|
|
||||||
|
fun parseContentLine(line: String): IcsContentLine? {
|
||||||
|
var inQuote = false
|
||||||
|
var colon = -1
|
||||||
|
for (i in line.indices) {
|
||||||
|
when (line[i]) {
|
||||||
|
'"' -> inQuote = !inQuote
|
||||||
|
':' -> if (!inQuote) { colon = i; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colon < 0) return null
|
||||||
|
val head = splitUnquoted(line.substring(0, colon), ';')
|
||||||
|
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
val params = buildMap {
|
||||||
|
for (part in head.drop(1)) {
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
put(
|
||||||
|
part.substring(0, eq).trim().uppercase(),
|
||||||
|
part.substring(eq + 1).trim().removeSurrounding("\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsContentLine(name, params, line.substring(colon + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split on [delimiter] except where it falls inside a double-quoted run. */
|
||||||
|
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
val current = StringBuilder()
|
||||||
|
var inQuote = false
|
||||||
|
for (c in text) {
|
||||||
|
when {
|
||||||
|
c == '"' -> { inQuote = !inQuote; current.append(c) }
|
||||||
|
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
|
||||||
|
else -> current.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.add(current.toString())
|
||||||
|
return parts
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Default `PRODID` advertising the writer that produced the file. */
|
||||||
|
const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal
|
||||||
|
* library: we stay on `kotlinx-datetime` and own the output, exactly as
|
||||||
|
* `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller
|
||||||
|
* supplies [dtStamp] (the export moment) so the writer never reads a clock.
|
||||||
|
*
|
||||||
|
* Timezone rule (see plan 05, decision 1):
|
||||||
|
* - all-day → `VALUE=DATE`, no zone;
|
||||||
|
* - timed one-off → UTC instant with a `Z` suffix (an instant is an instant);
|
||||||
|
* - timed recurring → `TZID`-labelled local wall time, so the series stays
|
||||||
|
* anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import
|
||||||
|
* resolves the `TZID` against the OS tz database.
|
||||||
|
*/
|
||||||
|
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||||
|
|
||||||
|
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||||
|
val lines = buildList {
|
||||||
|
add("BEGIN:VCALENDAR")
|
||||||
|
add("VERSION:2.0")
|
||||||
|
add("PRODID:$prodId")
|
||||||
|
add("CALSCALE:GREGORIAN")
|
||||||
|
events.forEach { appendEvent(it, dtStamp) }
|
||||||
|
add("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
||||||
|
add("BEGIN:VEVENT")
|
||||||
|
add("UID:${event.uid}")
|
||||||
|
add("DTSTAMP:${utcStamp(dtStamp)}")
|
||||||
|
add("SUMMARY:${escapeText(event.summary)}")
|
||||||
|
appendTimes(event)
|
||||||
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||||
|
event.location?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("LOCATION:${escapeText(it)}") }
|
||||||
|
event.description?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("DESCRIPTION:${escapeText(it)}") }
|
||||||
|
add("STATUS:${statusValue(event.status)}")
|
||||||
|
add("TRANSP:${transpValue(event.availability)}")
|
||||||
|
event.calendarName?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
|
||||||
|
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
|
||||||
|
appendAlarm(minutes, event.summary)
|
||||||
|
}
|
||||||
|
add("END:VEVENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
||||||
|
event.isAllDay -> {
|
||||||
|
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
|
||||||
|
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
|
||||||
|
}
|
||||||
|
// Recurring: anchor to wall-clock in the event's own zone.
|
||||||
|
event.recurrenceRule?.isNotBlank() == true -> {
|
||||||
|
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
|
||||||
|
if (zone != null) {
|
||||||
|
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
|
||||||
|
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
|
||||||
|
} else {
|
||||||
|
// Unknown zone id → fall back to plain UTC instants.
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||||
|
add("BEGIN:VALARM")
|
||||||
|
add("ACTION:DISPLAY")
|
||||||
|
add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}")
|
||||||
|
add("TRIGGER:${triggerValue(minutes)}")
|
||||||
|
add("END:VALARM")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun statusValue(status: EventStatus): String = when (status) {
|
||||||
|
EventStatus.Confirmed -> "CONFIRMED"
|
||||||
|
EventStatus.Tentative -> "TENTATIVE"
|
||||||
|
EventStatus.Cancelled -> "CANCELLED"
|
||||||
|
}
|
||||||
|
|
||||||
|
// iCal TRANSP is binary; only an explicitly free event is TRANSPARENT.
|
||||||
|
fun transpValue(availability: Availability): String =
|
||||||
|
if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE"
|
||||||
|
|
||||||
|
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
||||||
|
fun triggerValue(minutes: Int): String =
|
||||||
|
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
||||||
|
|
||||||
|
fun utcStamp(instant: Instant): String =
|
||||||
|
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
|
||||||
|
|
||||||
|
fun localStamp(instant: Instant, zone: TimeZone): String =
|
||||||
|
basic(instant.toLocalDateTime(zone))
|
||||||
|
|
||||||
|
fun utcDate(instant: Instant): String {
|
||||||
|
val dt = instant.toLocalDateTime(TimeZone.UTC)
|
||||||
|
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
|
||||||
|
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill the create form from a single parsed `.ics` event (the "open one
|
||||||
|
* event" path). [calendarId] is left null so the form preselects the last-used
|
||||||
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
|
*/
|
||||||
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = summary,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
reminders = reminderMinutes.distinct().sorted(),
|
||||||
|
availability = availability,
|
||||||
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.qs
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.quicksettings.TileService
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick Settings tile: tapping it opens the create-event form on today — the
|
||||||
|
* same action as the launcher "New event" shortcut and the agenda widget's "+".
|
||||||
|
* A stateless action tile, so there is no on/off state to keep in sync.
|
||||||
|
*/
|
||||||
|
class NewEventTileService : TileService() {
|
||||||
|
|
||||||
|
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
|
||||||
|
// the only form available below UpsideDownCake, and is reached only there.
|
||||||
|
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||||
|
override fun onClick() {
|
||||||
|
super.onClick()
|
||||||
|
val today = Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
val intent = MainActivity.openCreateIntent(this, today)
|
||||||
|
// Launch only once the device is unlocked: creating an event behind the
|
||||||
|
// keyguard makes no sense, and the shade can't start an activity over a
|
||||||
|
// locked screen anyway.
|
||||||
|
unlockAndRun {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
val pending = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
startActivityAndCollapse(pending)
|
||||||
|
} else {
|
||||||
|
startActivityAndCollapse(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,17 +15,24 @@ import androidx.compose.runtime.remember
|
|||||||
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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
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.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||||
@@ -42,6 +49,10 @@ fun CalendarHost(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
requestedDetailKey: LongArray? = null,
|
requestedDetailKey: LongArray? = null,
|
||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
@@ -87,6 +98,12 @@ fun CalendarHost(
|
|||||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
val onOpenSettings = { showSettings = true }
|
val onOpenSettings = { showSettings = true }
|
||||||
|
|
||||||
|
// Full-text search — its own overlay, opened from each calendar screen's
|
||||||
|
// top bar. Sits below the detail/edit overlays so tapping a result reveals
|
||||||
|
// the detail on top and backing out returns to the results.
|
||||||
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val onOpenSearch = { showSearch = true }
|
||||||
|
|
||||||
// Calendar manager (reached from Settings) — its own overlay so it slides
|
// Calendar manager (reached from Settings) — its own overlay so it slides
|
||||||
// over Settings and survives view switches.
|
// over Settings and survives view switches.
|
||||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -115,6 +132,61 @@ fun CalendarHost(
|
|||||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
|
||||||
|
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||||
|
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||||
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
LaunchedEffect(requestedImportUri) {
|
||||||
|
if (requestedImportUri != null) {
|
||||||
|
importUri = requestedImportUri
|
||||||
|
onImportConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close every overlay that can sit over the calendar, so an externally
|
||||||
|
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||||
|
// top instead of underneath whatever the user had open.
|
||||||
|
fun dismissCoveringOverlays() {
|
||||||
|
showSettings = false
|
||||||
|
showCalendars = false
|
||||||
|
detailKey = null
|
||||||
|
editKey = null
|
||||||
|
importUri = null
|
||||||
|
importForm = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||||
|
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||||
|
LaunchedEffect(widgetNavRequest) {
|
||||||
|
when (val req = widgetNavRequest) {
|
||||||
|
is WidgetNavRequest.OpenDate -> {
|
||||||
|
// Reveal the day view: drop any overlay that would cover it, so
|
||||||
|
// an external date-open doesn't land under an open Settings/form.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
|
pendingDayIso = req.dateIso
|
||||||
|
view = CalendarView.Day
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
is WidgetNavRequest.Create -> {
|
||||||
|
// External "new event" entries (QS tile / launcher shortcut /
|
||||||
|
// widget) must land on top of whatever is open — the form overlay
|
||||||
|
// sits below Settings/calendars in the Box, so without this it
|
||||||
|
// would open hidden underneath them.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
val iso = req.dateIso ?: Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||||
|
heldCreateIso = iso
|
||||||
|
createDateIso = iso
|
||||||
|
heldCreateMinutes = null
|
||||||
|
createStartMinutes = null
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -124,6 +196,7 @@ fun CalendarHost(
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
@@ -131,6 +204,7 @@ fun CalendarHost(
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
@@ -139,8 +213,30 @@ fun CalendarHost(
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
|
selectedView = view,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSearch,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
SearchScreen(
|
||||||
|
onBack = { showSearch = false },
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the live key; fall back to the held one only while sliding out.
|
// Prefer the live key; fall back to the held one only while sliding out.
|
||||||
@@ -219,5 +315,26 @@ fun CalendarHost(
|
|||||||
) {
|
) {
|
||||||
CalendarsScreen(onBack = { showCalendars = false })
|
CalendarsScreen(onBack = { showCalendars = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
|
// into the create form (prefilled, for review); many open the picker.
|
||||||
|
importUri?.let { uri ->
|
||||||
|
ImportScreen(
|
||||||
|
uri = uri,
|
||||||
|
onClose = { importUri = null },
|
||||||
|
onOpenSingle = { form ->
|
||||||
|
importUri = null
|
||||||
|
importForm = form
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
importForm?.let { form ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = null,
|
||||||
|
initialForm = form,
|
||||||
|
onClose = { importForm = null },
|
||||||
|
onSaved = { importForm = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ fun RootScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
requestedDetailKey: LongArray? = null,
|
requestedDetailKey: LongArray? = null,
|
||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -58,6 +62,10 @@ fun RootScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
requestedDetailKey = requestedDetailKey,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
widgetNavRequest = widgetNavRequest,
|
||||||
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = onImportConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A navigation a home-screen widget asked the app to perform when launched.
|
||||||
|
* Parsed from the launch intent in MainActivity and consumed once by
|
||||||
|
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
||||||
|
* they are not modelled here).
|
||||||
|
*/
|
||||||
|
sealed interface WidgetNavRequest {
|
||||||
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
||||||
|
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
||||||
|
|
||||||
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.PaddingValues
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AgendaScreen(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isOnToday = when (val s = state) {
|
||||||
|
is AgendaUiState.Success -> s.anchor == s.today
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
currentView = selectedView,
|
||||||
|
currentDate = anchor,
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
AgendaTopBar(
|
||||||
|
selectedView = selectedView,
|
||||||
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CalendarFabColumn(
|
||||||
|
todayVisible = !isOnToday,
|
||||||
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
|
onToday = viewModel::goToToday,
|
||||||
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaContent(
|
||||||
|
state: AgendaUiState,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
|
is AgendaUiState.Failure -> Box(modifier) {
|
||||||
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
|
}
|
||||||
|
is AgendaUiState.Success ->
|
||||||
|
if (state.days.isEmpty()) {
|
||||||
|
AgendaEmpty(modifier)
|
||||||
|
} else {
|
||||||
|
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun AgendaList(
|
||||||
|
state: AgendaUiState.Success,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
state.days.forEach { day ->
|
||||||
|
stickyHeader(key = "header-${day.date}") {
|
||||||
|
AgendaDayHeader(date = day.date, today = state.today)
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
items = day.events,
|
||||||
|
key = { _, event -> event.instanceId },
|
||||||
|
) { index, event ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
position = positionOf(index, day.events.size),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(date, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (date == today) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaEventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
GroupedRow(
|
||||||
|
title = title,
|
||||||
|
summary = agendaTimeSummary(event),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.EventAvailable,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_empty_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AgendaTopBar(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.view_agenda),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.month_open_menu),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ViewSwitcherPill(
|
||||||
|
current = selectedView,
|
||||||
|
onCycle = onCycleView,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||||
|
@Composable
|
||||||
|
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> stringResource(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val formatted = formatAgendaDate(date)
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||||
|
@Composable
|
||||||
|
private fun agendaTimeSummary(event: EventInstance): String {
|
||||||
|
val time = if (event.isAllDay) {
|
||||||
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||||
|
}
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(instant: Instant): String {
|
||||||
|
val t = instant.toLocalDateTime(zone).time
|
||||||
|
return "%02d:%02d".format(t.hour, t.minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/** One calendar day with at least one event, for the agenda list. */
|
||||||
|
data class AgendaDay(
|
||||||
|
val date: LocalDate,
|
||||||
|
/** Events on this day, all-day first then ascending by start time. */
|
||||||
|
val events: List<EventInstance>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||||
|
* actually carry events). An event that began before [anchor] (ongoing or
|
||||||
|
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||||
|
* day, all-day events sort first, then ascending by start time, then title.
|
||||||
|
*
|
||||||
|
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||||
|
* and order identically.
|
||||||
|
*/
|
||||||
|
fun groupAgendaDays(
|
||||||
|
anchor: LocalDate,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<AgendaDay> =
|
||||||
|
instances
|
||||||
|
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||||
|
.toSortedMap()
|
||||||
|
.map { (date, dayEvents) ->
|
||||||
|
AgendaDay(
|
||||||
|
date = date,
|
||||||
|
events = dayEvents.sortedWith(
|
||||||
|
compareByDescending<EventInstance> { it.isAllDay }
|
||||||
|
.thenBy { it.start }
|
||||||
|
.thenBy { it.title },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
|
* grouped by day (only days that actually have events appear).
|
||||||
|
*/
|
||||||
|
sealed interface AgendaUiState {
|
||||||
|
data object Loading : AgendaUiState
|
||||||
|
data class Failure(val reason: FailureReason) : AgendaUiState
|
||||||
|
data class Success(
|
||||||
|
/** First day of the loaded window (today, or a jumped-to date). */
|
||||||
|
val anchor: LocalDate,
|
||||||
|
val today: LocalDate,
|
||||||
|
val days: List<AgendaDay>,
|
||||||
|
) : AgendaUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
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.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** How far ahead the agenda loads events from its anchor day. */
|
||||||
|
internal const val AGENDA_WINDOW_DAYS = 60
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class AgendaViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
private val todayDate: LocalDate
|
||||||
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
val anchor: StateFlow<LocalDate> = _anchor
|
||||||
|
|
||||||
|
val state: StateFlow<AgendaUiState> = _anchor
|
||||||
|
.flatMapLatest { anchor ->
|
||||||
|
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(anchor, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = AgendaUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun goToToday() {
|
||||||
|
_anchor.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_anchor.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildState(
|
||||||
|
anchor: LocalDate,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): AgendaUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
val days = groupAgendaDays(anchor, instances, zone)
|
||||||
|
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||||
|
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||||
|
val from = anchor.atStartOfDayIn(zone)
|
||||||
|
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
|
return from..to
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
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
|
||||||
@@ -30,6 +32,7 @@ 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.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
import androidx.compose.material.icons.filled.OpenInNew
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -77,6 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
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
|
||||||
@@ -95,6 +99,7 @@ fun CalendarsScreen(
|
|||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
|
val backupResult by viewModel.backupResult.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
|
||||||
@@ -131,6 +136,9 @@ fun CalendarsScreen(
|
|||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
|
backupResult = backupResult,
|
||||||
|
onExportBackup = viewModel::exportBackup,
|
||||||
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
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 },
|
||||||
@@ -144,6 +152,9 @@ private fun CalendarsList(
|
|||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
|
backupResult: BackupResult?,
|
||||||
|
onExportBackup: (android.net.Uri) -> Unit,
|
||||||
|
onConsumeBackupResult: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
@@ -159,6 +170,31 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
|
// to the VM to stream the .ics into.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let(onExportBackup) }
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -195,6 +231,22 @@ private fun CalendarsList(
|
|||||||
onClick = onAdd,
|
onClick = onAdd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
|
// safety net. Offered only when there is something to back up.
|
||||||
|
if (local.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Synced calendars — read-only, grouped by account, each with a
|
// Synced calendars — read-only, grouped by account, each with a
|
||||||
@@ -429,6 +481,25 @@ private fun AccountHeader(account: String, accountType: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||||
|
@Composable
|
||||||
|
private fun LeadingAvatar(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddAvatar() {
|
private fun AddAvatar() {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
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.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.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -15,7 +18,9 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +32,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CalendarsViewModel @Inject constructor(
|
class CalendarsViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -45,6 +51,36 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun consumeError() { _error.value = false }
|
fun consumeError() { _error.value = false }
|
||||||
|
|
||||||
|
private val _backupResult = MutableStateFlow<BackupResult?>(null)
|
||||||
|
val backupResult: StateFlow<BackupResult?> = _backupResult.asStateFlow()
|
||||||
|
|
||||||
|
fun consumeBackupResult() { _backupResult.value = null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise every event of the writable local calendars into the chosen SAF
|
||||||
|
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||||
|
* in [backupResult] for a one-shot message.
|
||||||
|
*/
|
||||||
|
fun exportBackup(uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_backupResult.value = try {
|
||||||
|
val count = withContext(io) {
|
||||||
|
val events = repository.exportEvents()
|
||||||
|
icsExporter.writeDocument(
|
||||||
|
uri = uri,
|
||||||
|
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||||
|
)
|
||||||
|
events.size
|
||||||
|
}
|
||||||
|
BackupResult.Success(count)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
BackupResult.Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
||||||
repository.createLocalCalendar(displayName, color, description)
|
repository.createLocalCalendar(displayName, color, description)
|
||||||
}
|
}
|
||||||
@@ -69,3 +105,9 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||||
|
sealed interface BackupResult {
|
||||||
|
data class Success(val eventCount: Int) : BackupResult
|
||||||
|
data object Failure : BackupResult
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/** One UTC day in milliseconds — the unit the M3 [DatePicker] speaks. */
|
||||||
|
const val MILLIS_PER_DAY: Long = 86_400_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard Material 3 date picker, opened on [initial] and reporting
|
||||||
|
* the chosen day through [onConfirm]. Shared by the event form (start/end date,
|
||||||
|
* RRULE until) and the drawer's jump-to-date action.
|
||||||
|
*
|
||||||
|
* DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
|
||||||
|
* conversion zone-proof in both directions.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarDatePickerDialog(
|
||||||
|
initial: LocalDate,
|
||||||
|
onConfirm: (LocalDate) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
state.selectedDateMillis?.let { millis ->
|
||||||
|
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.dialog_ok)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DatePicker(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,12 +17,17 @@ 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.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
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
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.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
|
||||||
@@ -32,23 +37,31 @@ 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 kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation drawer shared by every top-level calendar screen.
|
* Navigation drawer shared by every top-level calendar screen.
|
||||||
*
|
*
|
||||||
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
|
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
|
||||||
* header, the View switcher as a grouped card (the active view highlighted),
|
* header, the View switcher as a grouped card (the active view highlighted),
|
||||||
* the per-calendar visibility filter (M3) inline, and a pinned Settings row.
|
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||||
* The "View" section mirrors the top-bar switcher pill — tapping a view here
|
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||||
* selects it (and closes the drawer) rather than cycling. The host screen owns
|
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||||
* the drawer state.
|
* The host screen owns the drawer state.
|
||||||
|
*
|
||||||
|
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||||
|
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
currentView: CalendarView,
|
currentView: CalendarView,
|
||||||
|
currentDate: LocalDate,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||||
// and Settings all flow in a single scroll container.
|
// and Settings all flow in a single scroll container.
|
||||||
@@ -71,6 +84,15 @@ fun CalendarDrawer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.drawer_jump_to_date),
|
||||||
|
position = Position.Alone,
|
||||||
|
minHeight = 56.dp,
|
||||||
|
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||||
|
onClick = { showDatePicker = true },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||||
@@ -87,6 +109,17 @@ fun CalendarDrawer(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
CalendarDatePickerDialog(
|
||||||
|
initial = currentDate,
|
||||||
|
onConfirm = {
|
||||||
|
showDatePicker = false
|
||||||
|
onJumpToDate(it)
|
||||||
|
},
|
||||||
|
onDismiss = { showDatePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Branded header: the app-icon chip beside the app name. */
|
/** Branded header: the app-icon chip beside the app name. */
|
||||||
|
|||||||
@@ -5,17 +5,16 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||||
|
import androidx.compose.material.icons.filled.ViewAgenda
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
/**
|
/** The top-level calendar views the user can switch between (spec M1). */
|
||||||
* The top-level calendar views the user can switch between (spec M1).
|
|
||||||
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
|
||||||
*/
|
|
||||||
enum class CalendarView {
|
enum class CalendarView {
|
||||||
Month,
|
Month,
|
||||||
Week,
|
Week,
|
||||||
Day,
|
Day,
|
||||||
|
Agenda,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
||||||
@@ -25,6 +24,7 @@ val CalendarView.labelRes: Int
|
|||||||
CalendarView.Month -> R.string.view_month
|
CalendarView.Month -> R.string.view_month
|
||||||
CalendarView.Week -> R.string.view_week
|
CalendarView.Week -> R.string.view_week
|
||||||
CalendarView.Day -> R.string.view_day
|
CalendarView.Day -> R.string.view_day
|
||||||
|
CalendarView.Agenda -> R.string.view_agenda
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leading icon for the view in the drawer's View section. */
|
/** Leading icon for the view in the drawer's View section. */
|
||||||
@@ -33,6 +33,7 @@ val CalendarView.icon: ImageVector
|
|||||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||||
|
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +41,7 @@ val CalendarView.icon: ImageVector
|
|||||||
* through these in order.
|
* through these in order.
|
||||||
*/
|
*/
|
||||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||||
|
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||||
|
* matches the card/grouped-row design language (not Material's outlined field).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogAmountField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
) {
|
||||||
|
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||||
|
// so anything lower vanishes.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { text ->
|
||||||
|
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
||||||
|
},
|
||||||
|
placeholder = placeholder,
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(72.dp)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||||
|
@Composable
|
||||||
|
fun DialogUnitDropdown(
|
||||||
|
label: String,
|
||||||
|
entries: List<String>,
|
||||||
|
onPick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
onClick = { open = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry) },
|
||||||
|
onClick = {
|
||||||
|
onPick(index)
|
||||||
|
open = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.ColumnScope
|
|||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -102,7 +104,19 @@ fun CollapsingScaffold(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
// Mark the scaffold's system-bar insets as consumed so the
|
||||||
|
// imePadding below adds only the keyboard height beyond them
|
||||||
|
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||||
|
// leaves an empty strip above the keyboard.
|
||||||
|
.consumeWindowInsets(innerPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
// Paint the surface across the full area before imePadding carves
|
||||||
|
// into it, so any sliver above the keyboard reads as surface — not
|
||||||
|
// the dialog window's black — during the IME animation.
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||||
|
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||||
|
.imePadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(top = 8.dp, bottom = 24.dp),
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
content = content,
|
content = content,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
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.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -36,6 +38,9 @@ fun InlineTextField(
|
|||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
|
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
||||||
|
onImeAction: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val resolvedStyle = textStyle.copy(
|
val resolvedStyle = textStyle.copy(
|
||||||
color = if (textStyle.color.isSpecified) {
|
color = if (textStyle.color.isSpecified) {
|
||||||
@@ -53,7 +58,11 @@ fun InlineTextField(
|
|||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = capitalization,
|
capitalization = capitalization,
|
||||||
|
imeAction = imeAction,
|
||||||
),
|
),
|
||||||
|
keyboardActions = onImeAction?.let { action ->
|
||||||
|
KeyboardActions(onAny = { action() })
|
||||||
|
} ?: KeyboardActions.Default,
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box {
|
Box {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private val DotSize = 8.dp
|
||||||
|
private val LineThickness = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current wall-clock instant that updates once a minute, re-aligning to each
|
||||||
|
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
|
||||||
|
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberCurrentMinute(): State<Instant> {
|
||||||
|
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
instant.value = now
|
||||||
|
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instant
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin "current time" indicator — a leading dot plus a line — drawn across a
|
||||||
|
* day column. Positioned on the same [hourHeight] scale the event blocks use so
|
||||||
|
* it lines up with the grid, and refreshed each minute. Renders nothing unless
|
||||||
|
* the wall clock is on [date]; callers mount it only for the column showing
|
||||||
|
* today, so the per-minute tick runs on a single column.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NowLine(
|
||||||
|
date: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val now by rememberCurrentMinute()
|
||||||
|
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
if (local.date != date) return
|
||||||
|
|
||||||
|
val minutes = local.hour * 60 + local.minute
|
||||||
|
val top = hourHeight * (minutes / 60f)
|
||||||
|
val color = MaterialTheme.colorScheme.primary
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(DotSize)
|
||||||
|
.offset(y = top - DotSize / 2),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(LineThickness)
|
||||||
|
.background(color),
|
||||||
|
)
|
||||||
|
// The dot anchors the line to the gutter edge, mirroring the standard
|
||||||
|
// calendar "now" marker; drawn after the line so it sits on top.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(DotSize)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
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.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
|
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||||
|
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||||
|
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FullScreenPicker(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// The dialog window pans by default when the keyboard opens, which —
|
||||||
|
// combined with the content's own imePadding — leaves a fixed black gap
|
||||||
|
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||||
|
// full-screen and imePadding alone lifts the focused field.
|
||||||
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
}
|
||||||
|
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General single-select picker, full-screen: each option is a connected grouped
|
||||||
|
* row and the current one carries a check. Drop-in for the former dialog
|
||||||
|
* (theme, week start, language, …).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(option),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||||
|
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||||
|
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||||
|
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderDefaultPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: CalendarReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
|
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||||
|
|
||||||
|
val options = buildList {
|
||||||
|
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||||
|
add(CalendarReminderOverride.None)
|
||||||
|
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||||
|
}
|
||||||
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderOverrideLabel(option),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
stringResource(
|
||||||
|
R.string.reminder_custom_with_value,
|
||||||
|
reminderLeadTimeLabel(selectedMinutes!!),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_edit_reminder_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = customExpanded) {
|
||||||
|
CustomReminderEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
|
* container). An amount field with a live preview of the resulting lead time, a
|
||||||
|
* single-choice unit toggle, and a tonal confirm enabled only for a valid
|
||||||
|
* 1–999 amount. [onConfirm] receives the final lead time in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomReminderEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Unit toggle first so it stays visible above the keyboard once the
|
||||||
|
// amount field (the bottom row) is focused and scrolled into view.
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = unit == entry,
|
||||||
|
onClick = { onUnitChange(entry) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||||
|
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||||
|
// all on one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
enabled = amount != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedCheck() {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||||
|
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||||
|
minutes == null -> "" to ReminderUnit.Minutes
|
||||||
|
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||||
|
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||||
|
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||||
|
else -> minutes.toString() to ReminderUnit.Minutes
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||||
|
enum class ReminderUnit(val minutesFactor: Int) {
|
||||||
|
Minutes(1),
|
||||||
|
Hours(60),
|
||||||
|
Days(1_440),
|
||||||
|
Weeks(10_080),
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
||||||
|
ReminderUnit.Days -> R.string.reminder_unit_days
|
||||||
|
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
|
* line: "Default reminder" (negative = the provider default), "At time of
|
||||||
|
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
||||||
|
* screen, the event form and the default-reminder settings so the wording
|
||||||
|
* never drifts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||||
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
|
minutes % 10_080 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
|
minutes % 1_440 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||||
|
minutes % 60 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 time picker in an alert dialog, seeded with [initial]. Shared by the event
|
||||||
|
* form (start/end times) and Settings (the all-day reminder fire time).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TimePickerAlert(
|
||||||
|
initial: LocalTime,
|
||||||
|
onConfirm: (LocalTime) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberTimePickerState(
|
||||||
|
initialHour = initial.hour,
|
||||||
|
initialMinute = initial.minute,
|
||||||
|
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
||||||
|
Text(stringResource(R.string.dialog_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the clock should read 24-hour, matching the rest of the device.
|
||||||
|
*
|
||||||
|
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
||||||
|
* the *app's* context locale — and this app applies a per-app language
|
||||||
|
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
||||||
|
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
||||||
|
* system 12/24 override, and otherwise fall back to the **device** locale
|
||||||
|
* (Resources.getSystem), not the app's.
|
||||||
|
*/
|
||||||
|
private fun deviceUses24HourClock(context: Context): Boolean =
|
||||||
|
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
||||||
|
"24" -> true
|
||||||
|
"12" -> false
|
||||||
|
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
||||||
|
else -> {
|
||||||
|
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
||||||
|
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately minimal, standalone surface for a captured crash report.
|
||||||
|
* `MainActivity` routes here when it detects a startup crash-loop (see
|
||||||
|
* [CrashReporter.isCrashLoop]): the main UI can't be trusted to start, so this
|
||||||
|
* screen stays clear of the app's Hilt graph, DataStore-backed theme and
|
||||||
|
* Compose content — it only reads the report file and shows the report dialog.
|
||||||
|
* Plain [CalendulaTheme] defaults (follow-system, dynamic colour) avoid touching
|
||||||
|
* anything that might be the cause of the crash.
|
||||||
|
*/
|
||||||
|
class CrashReportActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val report = CrashReporter.pendingReport(this)
|
||||||
|
if (report == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
CalendulaTheme {
|
||||||
|
// Opaque backdrop so the dialog doesn't float over a bare task.
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this, report)
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Reaching this screen breaks the loop; reset the timing trail so a
|
||||||
|
// later ordinary crash isn't mistaken for a loop.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the user to send a captured crash report as an issue. The full report is
|
||||||
|
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
||||||
|
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
||||||
|
* off to [submitCrashReport]; [onDismiss] declines.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CrashReportDialog(
|
||||||
|
report: String,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
||||||
|
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.crash_dialog_message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = report,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 220.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the captured crash report off to the user's chosen channel: the report
|
||||||
|
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
||||||
|
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
||||||
|
* sent automatically — the app has no network access; the user reviews and
|
||||||
|
* submits the issue themselves.
|
||||||
|
*/
|
||||||
|
fun submitCrashReport(context: Context, report: String) {
|
||||||
|
copyReportToClipboard(context, report)
|
||||||
|
val opened = runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
||||||
|
}.isSuccess
|
||||||
|
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
||||||
|
fun openIssueTracker(context: Context) {
|
||||||
|
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
||||||
|
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyReportToClipboard(context: Context, report: String) {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||||
|
val label = context.getString(R.string.crash_report_clip_label)
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
||||||
|
* can blow past URL-length limits, so an over-long one is left out of the link
|
||||||
|
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
||||||
|
* source of truth in that case.
|
||||||
|
*/
|
||||||
|
private fun buildIssueUri(context: Context, report: String) =
|
||||||
|
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
||||||
|
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
||||||
|
.appendQueryParameter("body", buildIssueBody(context, report))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun buildIssueBody(context: Context, report: String): String {
|
||||||
|
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||||
|
context.getString(R.string.crash_report_body_paste)
|
||||||
|
} else {
|
||||||
|
"```\n$report\n```"
|
||||||
|
}
|
||||||
|
return context.getString(R.string.crash_report_body_template, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||||
|
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||||
@@ -26,6 +26,7 @@ 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.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
@@ -71,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
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.NowLine
|
||||||
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.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -105,6 +107,7 @@ fun DayScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
@@ -151,6 +154,11 @@ fun DayScreen(
|
|||||||
}
|
}
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (target < date) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -159,10 +167,15 @@ fun DayScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
|
currentDate = date,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -178,6 +191,7 @@ fun DayScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -330,6 +344,7 @@ private fun DayTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -348,6 +363,12 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
@@ -486,6 +507,7 @@ private fun Timeline(
|
|||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = state.date,
|
date = state.date,
|
||||||
|
today = state.today,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -502,6 +524,7 @@ private fun DayColumnCard(
|
|||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -546,6 +569,10 @@ private fun DayColumnCard(
|
|||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Current-time line, on top of the events, only on today's column.
|
||||||
|
if (date == today) {
|
||||||
|
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Place
|
|||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -59,6 +60,7 @@ 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.rememberCoroutineScope
|
||||||
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
|
||||||
@@ -67,7 +69,6 @@ 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.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
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
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
@@ -96,6 +97,8 @@ import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -132,9 +135,30 @@ fun EventDetailScreen(
|
|||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
|
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||||
|
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
val onShareClick = {
|
||||||
|
scope.launch {
|
||||||
|
val uri = viewModel.shareUri()
|
||||||
|
val sent = uri != null && runCatching {
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/calendar"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
||||||
|
}.isSuccess
|
||||||
|
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action.
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
var pendingEdit by remember { mutableStateOf(false) }
|
||||||
@@ -203,9 +227,18 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Only writable calendars get actions — WebCal subscriptions,
|
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
|
||||||
val s = state
|
val s = state
|
||||||
|
// Share works for any loaded event — it only reads the event.
|
||||||
|
if (s is EventDetailUiState.Success) {
|
||||||
|
IconButton(onClick = onShareClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Share,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_share),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
if (s is EventDetailUiState.Success && s.canModify) {
|
if (s is EventDetailUiState.Success && s.canModify) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onEditClick,
|
onClick = onEditClick,
|
||||||
@@ -557,10 +590,22 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
val hasName = attendee.name.isNotBlank()
|
||||||
Text(
|
Text(
|
||||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
text = if (hasName) attendee.name else attendee.email.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
|
// Email as supporting text — only when the name is the headline, so we
|
||||||
|
// don't repeat it on rows that already fall back to the email above.
|
||||||
|
if (hasName) {
|
||||||
|
attendee.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||||
|
Text(
|
||||||
|
text = email,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
attendeeRoleLabel(attendee)?.let { roleRes ->
|
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(roleRes),
|
text = stringResource(roleRes),
|
||||||
@@ -684,26 +729,7 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
|||||||
|
|
||||||
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLeadText(reminder: Reminder): String {
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
val minutes = reminder.minutes
|
|
||||||
return when {
|
|
||||||
minutes < 0 -> stringResource(R.string.reminder_default)
|
|
||||||
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 -> {
|
|
||||||
val weeks = minutes / 10_080
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
|
|
||||||
}
|
|
||||||
minutes % 1_440 == 0 -> {
|
|
||||||
val days = minutes / 1_440
|
|
||||||
pluralStringResource(R.plurals.reminder_days, days, days)
|
|
||||||
}
|
|
||||||
minutes % 60 == 0 -> {
|
|
||||||
val hours = minutes / 60
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, hours, hours)
|
|
||||||
}
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||||
@@ -762,14 +788,19 @@ private fun formatWhen(
|
|||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|
||||||
if (instance.isAllDay) {
|
if (instance.isAllDay) {
|
||||||
// All-day end is the exclusive next midnight; step back to the last
|
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||||
// covered day so a one-day event reads as a single date.
|
// the covered dates in UTC — not the device zone, which would shift the
|
||||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
// midnight boundaries off the intended date (east of UTC pushes the
|
||||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
// end past the last day; west of UTC pulls the start back) — and step
|
||||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
// the end back to the last covered day so a one-day event reads as a
|
||||||
|
// single date.
|
||||||
|
val utc = ZoneId.of("UTC")
|
||||||
|
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
return if (startDate == lastDate) {
|
||||||
|
allDayLabel to dateFull.format(startDate)
|
||||||
} else {
|
} else {
|
||||||
allDayLabel to
|
allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
|
||||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
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.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
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.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -34,6 +40,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EventDetailViewModel @Inject constructor(
|
class EventDetailViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -113,6 +120,24 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
_deleteState.value = DeleteUiState.Idle
|
_deleteState.value = DeleteUiState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise the open event to a `.ics` cache file and return a shareable
|
||||||
|
* content Uri (for an ACTION_SEND), or null when nothing is loaded or the
|
||||||
|
* write fails. The event is exported as a one-off (see [toShareIcsEvent]).
|
||||||
|
*/
|
||||||
|
suspend fun shareUri(): Uri? {
|
||||||
|
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||||
|
return runCatching {
|
||||||
|
withContext(io) {
|
||||||
|
val ics = IcsWriter().writeCalendar(
|
||||||
|
events = listOf(detail.toShareIcsEvent()),
|
||||||
|
dtStamp = Clock.System.now(),
|
||||||
|
)
|
||||||
|
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
@@ -143,3 +168,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
private fun shareFileName(title: String): String {
|
||||||
|
val base = title.trim()
|
||||||
|
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
||||||
|
.replace(' ', '_')
|
||||||
|
.take(40)
|
||||||
|
.ifBlank { "event" }
|
||||||
|
return "$base.ics"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.util.Patterns
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -30,7 +36,6 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
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.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
@@ -38,10 +43,14 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
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.Contacts
|
||||||
import androidx.compose.material.icons.filled.EventAvailable
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
@@ -49,13 +58,11 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
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.AssistChip
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.DatePicker
|
|
||||||
import androidx.compose.material3.DatePickerDialog
|
|
||||||
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
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -70,11 +77,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TimePicker
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
|
||||||
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
|
||||||
@@ -87,14 +91,17 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
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.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
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -103,7 +110,9 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
@@ -113,10 +122,21 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
|||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
|
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.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -153,19 +173,23 @@ fun EventEditScreen(
|
|||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
|
initialForm: EventForm? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
if (editKey != null) {
|
when {
|
||||||
viewModel.openForEdit(
|
// Single-event .ics open: the form arrives prefilled for review.
|
||||||
|
initialForm != null -> viewModel.openImported(initialForm)
|
||||||
|
editKey != null -> viewModel.openForEdit(
|
||||||
eventId = editKey[0],
|
eventId = editKey[0],
|
||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
endMillis = editKey[2],
|
endMillis = editKey[2],
|
||||||
)
|
)
|
||||||
} else {
|
else -> {
|
||||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
viewModel.openNew(date, initialStartMinutes)
|
viewModel.openNew(date, initialStartMinutes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -421,6 +445,37 @@ private fun EventEditContent(
|
|||||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||||
|
// email row (email + display name). No READ_CONTACTS — the result Intent
|
||||||
|
// grants temporary read access, so Calendula stays no-network/no-permission.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pickContact = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
result.data?.data?.let { uri ->
|
||||||
|
readContactEmail(context, uri)?.let { (email, name) ->
|
||||||
|
viewModel.addAttendee(email, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a postal address from contacts: the same one-shot picker, scoped to
|
||||||
|
// address rows, dropping the chosen contact's formatted address into the
|
||||||
|
// location field. Same no-permission guarantee as the guest picker above.
|
||||||
|
val pickContactAddress = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
result.data?.data?.let { uri ->
|
||||||
|
readContactAddress(context, uri)?.let { address ->
|
||||||
|
viewModel.setLocation(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
// 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.
|
||||||
@@ -552,11 +607,39 @@ private fun EventEditContent(
|
|||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
) {
|
) {
|
||||||
InlineField(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
value = form.location,
|
InlineField(
|
||||||
onValueChange = viewModel::setLocation,
|
value = form.location,
|
||||||
placeholder = stringResource(R.string.event_detail_location),
|
onValueChange = viewModel::setLocation,
|
||||||
)
|
placeholder = stringResource(R.string.event_detail_location),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
pickContactAddress.launch(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_PICK,
|
||||||
|
ContactsContract.CommonDataKinds.StructuredPostal
|
||||||
|
.CONTENT_URI,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Contacts,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
R.string.event_edit_location_from_contacts,
|
||||||
|
),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,33 +662,24 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
// Reminders stack one per row (remove on the right edge); the
|
// A grouped list like guests: each reminder is its own tonal card
|
||||||
// add chip closes the card at the bottom. The card icon centres
|
// with connected corners, the last row being "Add reminder".
|
||||||
// on the first row, whatever that is.
|
val reminders = form.reminders
|
||||||
EditCard(
|
val rows = reminders.size + 1
|
||||||
icon = Icons.Default.Notifications,
|
reminders.forEachIndexed { index, minutes ->
|
||||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
GroupedItemCard(
|
||||||
header = {
|
position = positionOf(index, rows),
|
||||||
when (val first = form.reminders.firstOrNull()) {
|
icon = Icons.Default.Notifications,
|
||||||
null -> AddReminderChip(onClick = { showReminderPicker = true })
|
title = reminderLabel(minutes),
|
||||||
else -> ReminderRow(
|
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||||
label = reminderLabel(first),
|
onRemove = { viewModel.removeReminder(minutes) },
|
||||||
onRemove = { viewModel.removeReminder(first) },
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
form.reminders.drop(1).forEach { minutes ->
|
|
||||||
ReminderRow(
|
|
||||||
label = reminderLabel(minutes),
|
|
||||||
onRemove = { viewModel.removeReminder(minutes) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (form.reminders.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
AddReminderChip(onClick = { showReminderPicker = true })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
AddActionCard(
|
||||||
|
label = stringResource(R.string.event_edit_add_reminder),
|
||||||
|
position = positionOf(reminders.size, rows),
|
||||||
|
onClick = { showReminderPicker = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||||
@@ -768,6 +842,52 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) {
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
// Guests are an inline grouped list: each is a tonal card with a
|
||||||
|
// role toggle + remove, and the trailing card is an inline email
|
||||||
|
// field — type an address, press Done, it commits. Below sits an
|
||||||
|
// honest note — Calendula never sends invitations, so whether a
|
||||||
|
// guest is notified is the calendar backend's call.
|
||||||
|
val guests = form.attendees
|
||||||
|
val rows = guests.size + 1
|
||||||
|
guests.forEachIndexed { index, attendee ->
|
||||||
|
GuestCard(
|
||||||
|
attendee = attendee,
|
||||||
|
position = positionOf(index, rows),
|
||||||
|
onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) },
|
||||||
|
onRemove = { viewModel.removeAttendee(attendee) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AddGuestInlineCard(
|
||||||
|
position = positionOf(guests.size, rows),
|
||||||
|
onAdd = { viewModel.addAttendee(it) },
|
||||||
|
onPickContact = {
|
||||||
|
runCatching {
|
||||||
|
pickContact.launch(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_PICK,
|
||||||
|
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (isLocalCalendar) {
|
||||||
|
R.string.event_edit_attendees_note_local
|
||||||
|
} else {
|
||||||
|
R.string.event_edit_attendees_note_synced
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -786,12 +906,12 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (picker) {
|
when (picker) {
|
||||||
PickerTarget.StartDate -> DatePickerAlert(
|
PickerTarget.StartDate -> CalendarDatePickerDialog(
|
||||||
initial = form.start.date,
|
initial = form.start.date,
|
||||||
onConfirm = { viewModel.setStartDate(it); picker = null },
|
onConfirm = { viewModel.setStartDate(it); picker = null },
|
||||||
onDismiss = { picker = null },
|
onDismiss = { picker = null },
|
||||||
)
|
)
|
||||||
PickerTarget.EndDate -> DatePickerAlert(
|
PickerTarget.EndDate -> CalendarDatePickerDialog(
|
||||||
initial = form.end.date,
|
initial = form.end.date,
|
||||||
onConfirm = { viewModel.setEndDate(it); picker = null },
|
onConfirm = { viewModel.setEndDate(it); picker = null },
|
||||||
onDismiss = { picker = null },
|
onDismiss = { picker = null },
|
||||||
@@ -832,6 +952,7 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
@@ -917,14 +1038,7 @@ private fun FieldPickerDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||||
private val REMINDER_QUICK_PICKS = listOf(0, 10, 30, 60, 1_440)
|
private val REMINDER_QUICK_PICKS = REMINDER_PRESETS
|
||||||
|
|
||||||
private enum class ReminderUnit(val minutesFactor: Int) {
|
|
||||||
Minutes(1),
|
|
||||||
Hours(60),
|
|
||||||
Days(1_440),
|
|
||||||
Weeks(10_080),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder picker, two steps: the common lead times as a tappable list
|
* Reminder picker, two steps: the common lead times as a tappable list
|
||||||
@@ -1178,7 +1292,7 @@ private fun RecurrencePickerDialog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (showUntilPicker) {
|
if (showUntilPicker) {
|
||||||
DatePickerAlert(
|
CalendarDatePickerDialog(
|
||||||
initial = untilDate ?: LocalDate.fromEpochDays(
|
initial = untilDate ?: LocalDate.fromEpochDays(
|
||||||
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
|
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
|
||||||
),
|
),
|
||||||
@@ -1246,84 +1360,6 @@ private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMa
|
|||||||
private fun Int.toDaySet(): Set<DayOfWeek> =
|
private fun Int.toDaySet(): Set<DayOfWeek> =
|
||||||
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
||||||
|
|
||||||
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
|
|
||||||
@Composable
|
|
||||||
private fun DialogAmountField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
) {
|
|
||||||
// surfaceContainerHighest — the dialog itself sits on
|
|
||||||
// surfaceContainerHigh, so anything lower vanishes.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
InlineField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = { text ->
|
|
||||||
if (text.length <= 3 && text.all(Char::isDigit)) {
|
|
||||||
onValueChange(text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = placeholder,
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(72.dp)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
|
|
||||||
@Composable
|
|
||||||
private fun DialogUnitDropdown(
|
|
||||||
label: String,
|
|
||||||
entries: List<String>,
|
|
||||||
onPick: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
var open by remember { mutableStateOf(false) }
|
|
||||||
Box {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
onClick = { open = true },
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 14.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
top = 12.dp,
|
|
||||||
bottom = 12.dp,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
|
||||||
entries.forEachIndexed { index, entry ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(entry) },
|
|
||||||
onClick = {
|
|
||||||
onPick(index)
|
|
||||||
open = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||||
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
||||||
@@ -1339,51 +1375,273 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
|||||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
|
||||||
@Composable
|
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
|
||||||
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
when (position) {
|
||||||
Row(
|
Position.Alone -> RoundedCornerShape(full)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Position.Top -> RoundedCornerShape(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) {
|
Position.Middle -> RoundedCornerShape(small)
|
||||||
|
Position.Bottom -> RoundedCornerShape(
|
||||||
|
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||||
|
private fun groupedGap(position: Position): Modifier = when (position) {
|
||||||
|
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
Position.Bottom, Position.Alone -> Modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in a grouped form list (a guest, a reminder): a leading [icon],
|
||||||
|
* a [title] with optional [supporting] line, and a remove button — all on a
|
||||||
|
* tonal card whose corners come from its [position] in the run.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun GroupedItemCard(
|
||||||
|
position: Position,
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
removeContentDescription: String,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
supporting: String? = null,
|
||||||
|
titleMaxLines: Int = Int.MAX_VALUE,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = groupedShape(position),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(groupedGap(position)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = icon,
|
||||||
contentDescription = stringResource(R.string.event_edit_remove_reminder),
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp),
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = titleMaxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (supporting != null) {
|
||||||
|
Text(
|
||||||
|
text = supporting,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trailing != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = removeContentDescription,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One guest as a grouped-list card: avatar glyph, name/email, a tappable
|
||||||
|
* Required/Optional role chip, and remove. The email shows as the supporting
|
||||||
|
* line when a name is present, otherwise as the title.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun GuestCard(
|
||||||
|
attendee: EventAttendee,
|
||||||
|
position: Position,
|
||||||
|
onToggleOptional: (Boolean) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
val hasName = attendee.name.isNotBlank()
|
||||||
|
GroupedItemCard(
|
||||||
|
position = position,
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
title = if (hasName) attendee.name else attendee.email,
|
||||||
|
supporting = attendee.email.takeIf { hasName },
|
||||||
|
titleMaxLines = 1,
|
||||||
|
removeContentDescription = stringResource(R.string.event_edit_remove_guest),
|
||||||
|
onRemove = onRemove,
|
||||||
|
trailing = {
|
||||||
|
FilterChip(
|
||||||
|
selected = attendee.optional,
|
||||||
|
onClick = { onToggleOptional(!attendee.optional) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (attendee.optional) {
|
||||||
|
R.string.event_edit_attendee_optional
|
||||||
|
} else {
|
||||||
|
R.string.event_edit_attendee_required
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The trailing "Add …" action card of a grouped form list (opens a picker). */
|
||||||
|
@Composable
|
||||||
|
private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = groupedShape(position),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(groupedGap(position)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trailing card of the guests list: an inline email field plus a "from
|
||||||
|
* contacts" button. Type an address and press Done (or tap +) to commit a guest;
|
||||||
|
* or pick from contacts to fill email + name at once. The field clears after,
|
||||||
|
* ready for the next. Matches the form's inline-field input style — no dialog.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddReminderChip(onClick: () -> Unit) {
|
private fun AddGuestInlineCard(
|
||||||
AssistChip(
|
position: Position,
|
||||||
onClick = onClick,
|
onAdd: (String) -> Unit,
|
||||||
label = { Text(stringResource(R.string.event_edit_add)) },
|
onPickContact: () -> Unit,
|
||||||
leadingIcon = {
|
) {
|
||||||
|
var text by rememberSaveable { mutableStateOf("") }
|
||||||
|
val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() }
|
||||||
|
fun commit() {
|
||||||
|
if (valid) {
|
||||||
|
onAdd(text.trim())
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = groupedShape(position),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(groupedGap(position)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Add,
|
imageVector = Icons.Default.PersonAdd,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
},
|
Spacer(Modifier.width(16.dp))
|
||||||
border = null,
|
InlineTextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
value = text,
|
||||||
)
|
onValueChange = { text = it },
|
||||||
|
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
onImeAction = { commit() },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
if (valid) {
|
||||||
|
IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_add_guest),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Contacts,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
/**
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
* Read the email + display name from a contact-pick result URI. No
|
||||||
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||||
ReminderUnit.Days -> R.string.reminder_unit_days
|
*/
|
||||||
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>? =
|
||||||
}
|
context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(
|
||||||
|
ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME,
|
||||||
|
),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return@use null
|
||||||
|
val email = cursor.getString(0).orEmpty().trim()
|
||||||
|
val name = cursor.getString(1).orEmpty().trim()
|
||||||
|
email.takeIf { it.isNotEmpty() }?.let { it to name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the formatted postal address from a contact-pick result URI. No
|
||||||
|
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||||
|
* The provider's formatted address is multi-line; collapse it to one line so
|
||||||
|
* it sits cleanly in the single-line location field.
|
||||||
|
*/
|
||||||
|
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||||
|
context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return@use null
|
||||||
|
cursor.getString(0).orEmpty()
|
||||||
|
.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.joinToString(", ")
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
@@ -1393,6 +1651,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
|
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
@@ -1403,6 +1662,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
|||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
EventFormField.Visibility -> Icons.Default.Lock
|
||||||
EventFormField.Color -> Icons.Default.Palette
|
EventFormField.Color -> Icons.Default.Palette
|
||||||
|
EventFormField.Attendees -> Icons.Default.People
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1518,16 +1778,7 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
|||||||
|
|
||||||
/** 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 = when {
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
minutes <= 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
|
||||||
minutes % 1_440 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
|
||||||
minutes % 60 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's DetailCard: tonal container,
|
* One info card mirroring the detail screen's DetailCard: tonal container,
|
||||||
@@ -1688,62 +1939,6 @@ private fun ScheduleRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun DatePickerAlert(
|
|
||||||
initial: LocalDate,
|
|
||||||
onConfirm: (LocalDate) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
|
|
||||||
// conversion zone-proof in both directions.
|
|
||||||
val state = rememberDatePickerState(
|
|
||||||
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
|
||||||
)
|
|
||||||
DatePickerDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
state.selectedDateMillis?.let { millis ->
|
|
||||||
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text(stringResource(R.string.dialog_ok)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
DatePicker(state = state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TimePickerAlert(
|
|
||||||
initial: LocalTime,
|
|
||||||
onConfirm: (LocalTime) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val state = rememberTimePickerState(
|
|
||||||
initialHour = initial.hour,
|
|
||||||
initialMinute = initial.minute,
|
|
||||||
)
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
|
||||||
Text(stringResource(R.string.dialog_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
text = { TimePicker(state = state) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarPickerDialog(
|
private fun CalendarPickerDialog(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
@@ -1775,5 +1970,3 @@ private fun CalendarPickerDialog(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val MILLIS_PER_DAY = 86_400_000L
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
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.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
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
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
|
||||||
@@ -29,6 +31,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@@ -71,6 +74,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// Set while the form edits an existing event instead of composing a new one.
|
// Set while the form edits an existing event instead of composing a new one.
|
||||||
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||||
private val _loadFailed = MutableStateFlow(false)
|
private val _loadFailed = MutableStateFlow(false)
|
||||||
|
// True once the user has hand-edited the reminders on a new event, which
|
||||||
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
|
// their choice. Reset with the form.
|
||||||
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
@@ -100,6 +107,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val editTarget: EditTarget?,
|
val editTarget: EditTarget?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val timed: Int?,
|
||||||
|
val allDay: Int?,
|
||||||
|
val timedOverrides: Map<Long, Int?>,
|
||||||
|
val allDayOverrides: Map<Long, Int?>,
|
||||||
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
@@ -194,6 +208,63 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||||
|
* the create form" path). [form] already carries the file's fields; its
|
||||||
|
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||||
|
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||||
|
* settings default never overwrites what the file specified. No-op when a
|
||||||
|
* form is already open, so the prefill survives configuration changes.
|
||||||
|
*/
|
||||||
|
fun openImported(form: EventForm) {
|
||||||
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
_remindersTouched.value = true
|
||||||
|
_revealed.value = form.populatedFields()
|
||||||
|
_form.value = form
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill a new event's reminders from the settings default — the all-day
|
||||||
|
* default for all-day events, otherwise the resolved calendar's per-calendar
|
||||||
|
* override or the global timed default. No-op while editing an existing event
|
||||||
|
* or once the user has hand-edited the reminders, so the auto-default never
|
||||||
|
* clobbers a manual choice. [calendarId] short-circuits the resolution after a
|
||||||
|
* calendar switch; null resolves it as the form does.
|
||||||
|
*/
|
||||||
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val defaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
|
val form = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
|
val default = resolveDefaultReminder(
|
||||||
|
timedGlobal = defaults.timed,
|
||||||
|
allDayGlobal = defaults.allDay,
|
||||||
|
timedOverrides = defaults.timedOverrides,
|
||||||
|
allDayOverrides = defaults.allDayOverrides,
|
||||||
|
calendarId = targetId,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
)
|
||||||
|
val reminders = listOfNotNull(default)
|
||||||
|
_form.value = form.copy(reminders = reminders)
|
||||||
|
// Surface the section so an auto-applied default is visible and
|
||||||
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
|
if (reminders.isNotEmpty()) {
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +300,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_revealed.value = emptySet()
|
_revealed.value = emptySet()
|
||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
|
_remindersTouched.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -239,14 +311,24 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
fun setAllDay(value: Boolean) {
|
||||||
|
update { it.copy(isAllDay = value) }
|
||||||
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching calendars drops any chosen colour: a palette key is
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
* The event falls back to the new calendar's colour until re-picked.
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
|
fun setCalendar(id: Long) {
|
||||||
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
|
// A fresh event re-inherits the new calendar's default reminder unless
|
||||||
|
// the user has already hand-edited it (guarded inside).
|
||||||
|
applyDefaultReminder(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) }
|
||||||
|
|
||||||
@@ -262,12 +344,41 @@ class EventEditViewModel @Inject constructor(
|
|||||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
fun addReminder(minutes: Int) = update {
|
fun addReminder(minutes: Int) {
|
||||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeReminder(minutes: Int) = update {
|
fun removeReminder(minutes: Int) {
|
||||||
it.copy(reminders = it.reminders - minutes)
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = it.reminders - minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
|
||||||
|
* address just refreshes it (no duplicate row). A blank email is ignored —
|
||||||
|
* the email is what we write and dedup on.
|
||||||
|
*/
|
||||||
|
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
||||||
|
val trimmed = email.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
update { form ->
|
||||||
|
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
||||||
|
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
||||||
|
form.copy(attendees = rest + attendee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAttendee(attendee: EventAttendee) =
|
||||||
|
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||||
|
|
||||||
|
/** Flip a guest between required and optional (the per-row role toggle). */
|
||||||
|
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
|
||||||
|
form.copy(
|
||||||
|
attendees = form.attendees.map { a ->
|
||||||
|
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moving the start drags the end along, preserving the duration. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
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.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
|
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||||
|
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||||
|
* Empty/failed files show a short message and close.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ImportScreen(
|
||||||
|
uri: Uri,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
|
||||||
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.import_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when (val s = state) {
|
||||||
|
ImportUiState.Loading,
|
||||||
|
ImportUiState.Importing,
|
||||||
|
is ImportUiState.Single,
|
||||||
|
-> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
|
||||||
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
|
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
||||||
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
||||||
|
// No writable calendar to import into — tell the user honestly.
|
||||||
|
if (state.calendars.isEmpty()) {
|
||||||
|
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_target_header),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
state.calendars.forEach { calendar ->
|
||||||
|
OptionCard(
|
||||||
|
label = calendar.displayName,
|
||||||
|
onClick = { selected = calendar.id },
|
||||||
|
selected = calendar.id == selected,
|
||||||
|
icon = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.warnings.forEach { WarningText(it) }
|
||||||
|
Button(
|
||||||
|
onClick = { onImport(selected) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_done_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_imported,
|
||||||
|
state.summary.imported,
|
||||||
|
state.summary.imported,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_skipped,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.import_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WarningText(warning: IcsParseWarning) {
|
||||||
|
val text = when (warning) {
|
||||||
|
IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
|
||||||
|
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||||
|
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||||
|
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (onClose != null) {
|
||||||
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
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.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** What an opened/received `.ics` resolved to. */
|
||||||
|
sealed interface ImportUiState {
|
||||||
|
data object Loading : ImportUiState
|
||||||
|
data object Importing : ImportUiState
|
||||||
|
|
||||||
|
/** The file held no importable event (or couldn't be read/parsed as one). */
|
||||||
|
data object Empty : ImportUiState
|
||||||
|
data object Failed : ImportUiState
|
||||||
|
|
||||||
|
/** Exactly one event → review it in the prefilled create form. */
|
||||||
|
data class Single(val form: EventForm, val warnings: Set<IcsParseWarning>) : ImportUiState
|
||||||
|
|
||||||
|
/** Several events → pick a target calendar and bulk-import. */
|
||||||
|
data class Many(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
) : ImportUiState
|
||||||
|
|
||||||
|
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
|
||||||
|
* one event opens the create form for review, many open the bulk-import picker.
|
||||||
|
* The bulk import dedups by UID in the repository.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ImportViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val importer: IcsImporter,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val parser = IcsParser()
|
||||||
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
||||||
|
fun load(uri: Uri) {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val parsed = withContext(io) {
|
||||||
|
importer.readText(uri)?.let(parser::parse)
|
||||||
|
}
|
||||||
|
_state.value = when {
|
||||||
|
parsed == null -> ImportUiState.Failed
|
||||||
|
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||||
|
parsed.events.size == 1 -> ImportUiState.Single(
|
||||||
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
)
|
||||||
|
else -> ImportUiState.Many(
|
||||||
|
events = parsed.events,
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
calendars = repository.calendars().first().filter { it.canModifyContents },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
||||||
|
fun import(targetCalendarId: Long) {
|
||||||
|
val many = _state.value as? ImportUiState.Many ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = ImportUiState.Importing
|
||||||
|
_state.value = try {
|
||||||
|
ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ImportUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -86,6 +87,7 @@ fun MonthScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
@@ -124,6 +126,11 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target month lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -132,10 +139,15 @@ fun MonthScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
|
currentDate = LocalDate(month.year, month.month, 1),
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -151,6 +163,7 @@ fun MonthScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -251,6 +264,7 @@ private fun MonthTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -269,6 +283,12 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ class MonthViewModel @Inject constructor(
|
|||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
ym: YearMonth,
|
ym: YearMonth,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
@@ -108,53 +113,57 @@ class MonthViewModel @Inject constructor(
|
|||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
weeks = layoutMonth(ym, weekStart, instances),
|
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split the grid into week rows and resolve each row's events. An event is a
|
* Split the month grid into week rows and resolve each row's events. An event is
|
||||||
* spanning bar when it's all-day or touches more than one of the row's days;
|
* a spanning bar when it's all-day or touches more than one of the row's days;
|
||||||
* everything else is a single-day timed pill. Bars get lanes from the shared
|
* everything else is a single-day timed pill. Bars get lanes from the shared
|
||||||
* [layoutAllDay] so a multi-day event stays on one row across the week.
|
* [layoutAllDay] so a multi-day event stays on one row across the week.
|
||||||
*/
|
*
|
||||||
private fun layoutMonth(
|
* Shared by the Month screen and the month home-screen widget so both lay out
|
||||||
ym: YearMonth,
|
* spans, lanes and per-day counts identically.
|
||||||
weekStart: DayOfWeek,
|
*/
|
||||||
instances: List<EventInstance>,
|
internal fun layoutMonthWeeks(
|
||||||
): List<MonthWeek> {
|
ym: YearMonth,
|
||||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
weekStart: DayOfWeek,
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
instances: List<EventInstance>,
|
||||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
zone: TimeZone,
|
||||||
val daysInMonth =
|
): List<MonthWeek> {
|
||||||
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||||
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||||
|
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
val daysInMonth =
|
||||||
|
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||||
|
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||||
}
|
}
|
||||||
val spans = layoutAllDay(bars, days, zone).map { s ->
|
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||||
MonthSpan(
|
MonthSpan(
|
||||||
event = s.event,
|
event = s.event,
|
||||||
startCol = s.startCol,
|
startCol = s.startCol,
|
||||||
endCol = s.endCol,
|
endCol = s.endCol,
|
||||||
lane = s.lane,
|
lane = s.lane,
|
||||||
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
|
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
|
||||||
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||||
)
|
|
||||||
}
|
|
||||||
MonthWeek(
|
|
||||||
days = days,
|
|
||||||
spans = spans,
|
|
||||||
timedByDay = days.associateWith { d ->
|
|
||||||
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
|
||||||
},
|
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
MonthWeek(
|
||||||
|
days = days,
|
||||||
|
spans = spans,
|
||||||
|
timedByDay = days.associateWith { d ->
|
||||||
|
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
||||||
|
},
|
||||||
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.PaddingValues
|
||||||
|
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.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import java.time.Instant as JavaInstant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text event search (top-bar entry). Type a query → matching events
|
||||||
|
* (title / location / description) across the whole calendar, newest-relevant
|
||||||
|
* first; tap a result to open its detail. A full-screen overlay hosted by
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
||||||
|
// stays composed under the detail), so backing out keeps the query.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.setQuery("")
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboard?.show()
|
||||||
|
}
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
InlineTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = viewModel::setQuery,
|
||||||
|
placeholder = stringResource(R.string.search_hint),
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
onImeAction = { keyboard?.hide() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.search_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.setQuery("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
// imePadding shrinks the content by the keyboard, so the centered
|
||||||
|
// idle/empty message re-centres in the space above it (and the results
|
||||||
|
// list lifts clear of the keyboard too).
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
SearchUiState.Idle -> SearchMessage(
|
||||||
|
icon = null,
|
||||||
|
text = stringResource(R.string.search_idle_hint),
|
||||||
|
)
|
||||||
|
is SearchUiState.Empty -> SearchMessage(
|
||||||
|
icon = Icons.Default.SearchOff,
|
||||||
|
text = stringResource(R.string.search_empty, s.query),
|
||||||
|
)
|
||||||
|
is SearchUiState.Results -> SearchResults(
|
||||||
|
events = s.events,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResults(
|
||||||
|
events: List<EventInstance>,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = events,
|
||||||
|
key = { _, event -> event.eventId },
|
||||||
|
) { index, event ->
|
||||||
|
SearchResultRow(
|
||||||
|
event = event,
|
||||||
|
position = positionOf(index, events.size),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultRow(
|
||||||
|
event: EventInstance,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
GroupedRow(
|
||||||
|
title = event.title,
|
||||||
|
summary = searchSummary(event),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||||
|
@Composable
|
||||||
|
private fun searchSummary(event: EventInstance): String {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
|
val start = remember(event.start, zone) {
|
||||||
|
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||||
|
}
|
||||||
|
val dateText = remember(locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
}.format(start)
|
||||||
|
val timeText = if (event.isAllDay) {
|
||||||
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
remember(locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
}.format(start)
|
||||||
|
}
|
||||||
|
val base = "$dateText · $timeText"
|
||||||
|
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchMessage(
|
||||||
|
icon: ImageVector?,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
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.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
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.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Shortest query that triggers a search; one character matches almost everything. */
|
||||||
|
private const val MIN_QUERY_LENGTH = 2
|
||||||
|
|
||||||
|
sealed interface SearchUiState {
|
||||||
|
/** No query yet, or one too short to search — show the prompt. */
|
||||||
|
data object Idle : SearchUiState
|
||||||
|
|
||||||
|
/** A query ran but matched nothing. */
|
||||||
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
|
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||||
|
data class Results(val events: List<EventInstance>) : SearchUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class SearchViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _query = MutableStateFlow("")
|
||||||
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
|
val state: StateFlow<SearchUiState> = _query
|
||||||
|
.debounce(250L)
|
||||||
|
.map { it.trim() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.mapLatest { q ->
|
||||||
|
if (q.length < MIN_QUERY_LENGTH) {
|
||||||
|
SearchUiState.Idle
|
||||||
|
} else {
|
||||||
|
val results = repository.searchEvents(q)
|
||||||
|
if (results.isEmpty()) SearchUiState.Empty(q)
|
||||||
|
else SearchUiState.Results(sortNearestFirst(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(SearchUiState.Idle) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||||
|
|
||||||
|
fun setQuery(value: String) {
|
||||||
|
_query.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||||
|
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val (upcoming, past) = events.partition { it.end >= now }
|
||||||
|
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,78 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/** UI-facing language choice. AUTO follows the system languages. */
|
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||||
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
|
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||||
* platform per-app-languages API; below that the appcompat backport persists
|
*
|
||||||
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
|
* That file is the single source of truth for which languages we ship: dropping
|
||||||
* it in DataStore. Setting a locale recreates the activity, which re-reads the
|
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||||
* current value for the dropdown.
|
* language show up here and in the system per-app-language settings, with no
|
||||||
|
* other code change. The system-default choice is represented as `null`.
|
||||||
|
*
|
||||||
|
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||||
|
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||||
|
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||||
|
* activity, which re-reads the current value for the picker.
|
||||||
*/
|
*/
|
||||||
object AppLanguage {
|
object AppLanguage {
|
||||||
|
|
||||||
fun current(): LanguagePref {
|
/**
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||||
if (locales.isEmpty) return LanguagePref.AUTO
|
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||||
return when (locales[0]?.language) {
|
* or malformed config yields an empty list (the picker then offers only the
|
||||||
"de" -> LanguagePref.GERMAN
|
* system-default entry rather than crashing).
|
||||||
"en" -> LanguagePref.ENGLISH
|
*/
|
||||||
else -> LanguagePref.AUTO
|
fun supportedTags(context: Context): List<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
val parser = context.resources.getXml(R.xml.locales_config)
|
||||||
|
try {
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fall back to whatever was parsed before the failure.
|
||||||
|
} finally {
|
||||||
|
parser.close()
|
||||||
}
|
}
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
fun apply(pref: LanguagePref) {
|
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||||
val locales = when (pref) {
|
fun currentTag(): String? {
|
||||||
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||||
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
}
|
||||||
|
|
||||||
|
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||||
|
fun apply(tag: String?) {
|
||||||
|
val locales = if (tag == null) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||||
|
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||||
|
* of the current UI language. Capitalised per the language's own rules.
|
||||||
|
*/
|
||||||
|
fun displayName(tag: String): String {
|
||||||
|
val locale = Locale.forLanguageTag(tag)
|
||||||
|
return locale.getDisplayName(locale)
|
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.StatusBarManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -30,21 +37,24 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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
|
||||||
@@ -57,23 +67,40 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
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.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
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.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||||
@@ -180,19 +207,59 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||||
onClick = onManageCalendars,
|
onClick = onManageCalendars,
|
||||||
)
|
)
|
||||||
LanguageRow(position = Position.Bottom)
|
LanguageRow(position = Position.Middle)
|
||||||
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
AppVersionText()
|
AppVersionText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the project's issue tracker to report a problem. If a crash report was
|
||||||
|
* captured (and not yet sent), it surfaces that report first via the same
|
||||||
|
* dialog the next-launch prompt uses; otherwise it opens the issue template
|
||||||
|
* chooser. No data leaves the device until the user submits the issue.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReportProblemRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var report by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_report_problem),
|
||||||
|
summary = stringResource(R.string.settings_report_problem_hint),
|
||||||
|
position = position,
|
||||||
|
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
|
||||||
|
onClick = {
|
||||||
|
val pending = CrashReporter.pendingReport(context)
|
||||||
|
if (pending != null) report = pending else openIssueTracker(context)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
report?.let { pending ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = pending,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(context, pending)
|
||||||
|
CrashReporter.clearReport(context)
|
||||||
|
report = null
|
||||||
|
},
|
||||||
|
onDismiss = { report = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// row updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.current()) }
|
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
summary = languageLabel(current),
|
summary = languageLabel(current),
|
||||||
@@ -202,9 +269,9 @@ private fun LanguageRow(position: Position) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
options = LanguagePref.entries,
|
options = options,
|
||||||
selected = current,
|
selected = current,
|
||||||
label = { languageLabel(it) },
|
label = { languageLabel(it) },
|
||||||
onSelect = {
|
onSelect = {
|
||||||
@@ -378,7 +445,7 @@ private fun AppearanceScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.themeMode,
|
selected = state.themeMode,
|
||||||
@@ -388,7 +455,7 @@ private fun AppearanceScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
options = WeekStartPref.entries,
|
options = WeekStartPref.entries,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
@@ -452,9 +519,39 @@ private fun EventFormScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||||
|
// is API 33+; on older versions the tile is still addable manually from
|
||||||
|
// the QS editor, so the row simply doesn't appear there.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val context = LocalContext.current
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_qs_tile),
|
||||||
|
summary = stringResource(R.string.settings_qs_tile_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { requestAddQsTile(context) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
|
||||||
|
* shows its own confirmation dialog and handles the already-added case, so no
|
||||||
|
* result handling is needed here.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
private fun requestAddQsTile(context: Context) {
|
||||||
|
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
|
||||||
|
statusBar.requestAddTileService(
|
||||||
|
ComponentName(context, NewEventTileService::class.java),
|
||||||
|
context.getString(R.string.qs_tile_new_event_label),
|
||||||
|
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
|
||||||
|
context.mainExecutor,
|
||||||
|
) { /* result code unused — the system surfaces its own feedback */ }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||||
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||||
@@ -482,6 +579,13 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -489,13 +593,304 @@ private fun NotificationsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
summary = stringResource(R.string.settings_reminders_hint),
|
summary = stringResource(R.string.settings_reminders_hint),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||||
},
|
},
|
||||||
onClick = { toggleReminders(!state.remindersEnabled) },
|
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,
|
||||||
|
formatTimeOfDay(context, state.allDayReminderTimeMinutes),
|
||||||
|
),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showAllDayReminderTime = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-calendar overrides: 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))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_calendar_reminders_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
state.writableCalendars.forEach { calendar ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
val expanded = calendar.id in expandedCalendars
|
||||||
|
// Calendar card; tapping expands it into a grouped list of three
|
||||||
|
// (the card + the timed and all-day override rows).
|
||||||
|
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) {
|
||||||
|
Column {
|
||||||
|
val timed = state.perCalendarReminderOverride.choiceFor(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.choiceFor(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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery reliability: Android's battery optimisation can delay or drop
|
||||||
|
// the calendar provider's reminder broadcast. A soft, optional exemption
|
||||||
|
// (system-settings deep-link, no special permission) improves on-time
|
||||||
|
// delivery; shown as live status, reversible by the user at any time.
|
||||||
|
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.Alone,
|
||||||
|
trailing = if (batteryExempt) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showSnooze = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSnooze) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
options = SNOOZE_PRESETS,
|
||||||
|
selected = state.snoozeMinutes,
|
||||||
|
label = { snoozeDurationLabel(it) },
|
||||||
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
|
onDismiss = { showSnooze = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDefaultReminder) {
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
presets = REMINDER_PRESETS,
|
||||||
|
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
||||||
|
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.toMinutesOrNull()) },
|
||||||
|
onDismiss = { showAllDayReminder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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.choiceFor(target.calendarId),
|
||||||
|
allowInherit = true,
|
||||||
|
onSelect = {
|
||||||
|
if (target.isAllDay) {
|
||||||
|
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
|
||||||
|
} else {
|
||||||
|
viewModel.setCalendarReminderOverride(target.calendarId, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { overrideDialog = 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 (null = none) as a picker choice for selection highlighting. */
|
||||||
|
private fun Int?.toReminderChoice(): CalendarReminderOverride =
|
||||||
|
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||||
|
|
||||||
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
|
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
|
||||||
|
(this as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
|
* `ON_RESUME` so the row reflects a change the user just made in system
|
||||||
|
* settings without needing to leave and re-enter the screen.
|
||||||
|
*/
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the user straight to Calendula's exemption: the direct
|
||||||
|
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
|
||||||
|
* battery optimisation?") rather than the full app list they'd have to scroll.
|
||||||
|
* Falls back to the optimisation list if the OS refuses the direct intent.
|
||||||
|
*/
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lead times offered for the all-day default — day-scale, since a "minutes
|
||||||
|
* before midnight" reminder on an all-day event is rarely what's wanted.
|
||||||
|
*/
|
||||||
|
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||||
|
|
||||||
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
|
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||||
|
@Composable
|
||||||
|
private fun snoozeDurationLabel(minutes: Int): String =
|
||||||
|
if (minutes % 60 == 0) {
|
||||||
|
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
||||||
|
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||||
|
val time = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, minutesOfDay / 60)
|
||||||
|
set(Calendar.MINUTE, minutesOfDay % 60)
|
||||||
|
}.time
|
||||||
|
return DateFormat.getTimeFormat(context).format(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||||
|
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||||
|
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||||
|
this[calendarId] == null -> CalendarReminderOverride.None
|
||||||
|
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for a global-default choice: null → "None", else the lead time. */
|
||||||
|
@Composable
|
||||||
|
private fun reminderChoiceLabel(minutes: Int?): String =
|
||||||
|
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
||||||
|
|
||||||
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
|
@Composable
|
||||||
|
private fun calendarOverrideSummary(
|
||||||
|
choice: CalendarReminderOverride,
|
||||||
|
globalDefault: Int?,
|
||||||
|
): String = when (choice) {
|
||||||
|
CalendarReminderOverride.Inherit ->
|
||||||
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -531,38 +926,6 @@ private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OptionCard selection dialog — the app's only sanctioned picker style. */
|
|
||||||
@Composable
|
|
||||||
private fun <T> OptionPickerDialog(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(title) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
options.forEach { option ->
|
|
||||||
OptionCard(
|
|
||||||
label = label(option),
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
selected = option == selected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openUrl(context: Context, url: String) {
|
private fun openUrl(context: Context, url: String) {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
@@ -577,6 +940,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
|
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -598,10 +962,5 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun languageLabel(pref: LanguagePref): String = stringResource(
|
private fun languageLabel(tag: String?): String =
|
||||||
when (pref) {
|
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||||
LanguagePref.AUTO -> R.string.settings_language_auto
|
|
||||||
LanguagePref.GERMAN -> R.string.settings_language_german
|
|
||||||
LanguagePref.ENGLISH -> R.string.settings_language_english
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,27 @@ data class SettingsUiState(
|
|||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||||
|
* null = no default reminder. Per-calendar overrides take precedence.
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Int? = null,
|
||||||
|
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||||
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
|
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||||
|
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
|
* calendar present in the map overrides the global default (null value = no
|
||||||
|
* reminder); absent = inherit the global default.
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||||
|
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||||
|
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||||
/**
|
/**
|
||||||
* Whether the event-colour picker is offered on calendars that publish no
|
* Whether the event-colour picker is offered on calendars that publish no
|
||||||
* colour palette (the colour may then not survive their next sync).
|
* colour palette (the colour may then not survive their next sync).
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ import android.os.Build
|
|||||||
import androidx.lifecycle.ViewModel
|
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.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -18,14 +24,20 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
repository: CalendarRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
/** Writable calendars — the only ones that take a per-calendar reminder override. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(
|
combine(
|
||||||
// combine() only types up to five flows, so the sixth pref folds
|
// combine() types up to five flows, so the prefs split into two
|
||||||
// into the assembled state in an outer combine.
|
// groups that fold together in the outer combine.
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
@@ -42,15 +54,55 @@ class SettingsViewModel @Inject constructor(
|
|||||||
remindersEnabled = reminders,
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
prefs.allowColorOnUnsupportedCalendars,
|
combine(
|
||||||
) { base, allowColor ->
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
prefs.defaultReminderMinutes,
|
||||||
|
prefs.defaultAllDayReminderMinutes,
|
||||||
|
prefs.allDayReminderTimeMinutes,
|
||||||
|
prefs.snoozeMinutes,
|
||||||
|
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
|
||||||
|
ReminderDefaults(
|
||||||
|
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
combine(
|
||||||
|
prefs.perCalendarReminderOverride,
|
||||||
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
|
writableCalendars,
|
||||||
|
) { overrides, allDayOverrides, calendars ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
|
},
|
||||||
|
) { base, defaults, overrides ->
|
||||||
|
base.copy(
|
||||||
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
|
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||||
|
snoozeMinutes = defaults.snoozeMinutes,
|
||||||
|
perCalendarReminderOverride = overrides.timed,
|
||||||
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
|
writableCalendars = overrides.calendars,
|
||||||
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val allowColor: Boolean,
|
||||||
|
val defaultReminder: Int?,
|
||||||
|
val allDayReminder: Int?,
|
||||||
|
val allDayReminderTime: Int,
|
||||||
|
val snoozeMinutes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderOverrides(
|
||||||
|
val timed: Map<Long, Int?>,
|
||||||
|
val allDay: Map<Long, Int?>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -71,6 +123,30 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
@@ -76,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
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.NowLine
|
||||||
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.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
@@ -113,6 +115,7 @@ fun WeekScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
@@ -156,6 +159,11 @@ fun WeekScreen(
|
|||||||
}
|
}
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target week lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (target < weekStart) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -164,10 +172,15 @@ fun WeekScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
|
currentDate = weekStart,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -183,6 +196,7 @@ fun WeekScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -341,6 +355,7 @@ private fun WeekTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -359,6 +374,12 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
@@ -600,6 +621,7 @@ private fun Timeline(
|
|||||||
blocks = state.timedByDay[day].orEmpty(),
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = day,
|
date = day,
|
||||||
|
today = state.today,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -618,6 +640,7 @@ private fun DayColumnCard(
|
|||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -661,6 +684,10 @@ private fun DayColumnCard(
|
|||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Current-time line, on top of the events, only on today's column.
|
||||||
|
if (date == today) {
|
||||||
|
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ class WeekViewModel @Inject constructor(
|
|||||||
_anchor.value = todayDate
|
_anchor.value = todayDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jump to the week containing [date] (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_anchor.value = date
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
start: LocalDate,
|
start: LocalDate,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
@@ -176,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
|||||||
|
|
||||||
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||||
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||||
|
if (isAllDay) {
|
||||||
|
// All-day events live at UTC midnights with an exclusive end. Compare
|
||||||
|
// calendar dates in UTC and step the exclusive end back to the last
|
||||||
|
// covered day (mirroring the detail/edit views), so a one-day event
|
||||||
|
// covers exactly its single date. Slicing the day in the device zone
|
||||||
|
// would push the exclusive end a few hours into the next local day
|
||||||
|
// east of UTC, making the event leak onto day + 1.
|
||||||
|
val startDate = start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
|
||||||
|
return day in startDate..lastDay
|
||||||
|
}
|
||||||
val dayStart = day.atStartOfDayIn(zone)
|
val dayStart = day.atStartOfDayIn(zone)
|
||||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
return start < dayEnd && end > dayStart
|
return start < dayEnd && end > dayStart
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
||||||
|
private const val AGENDA_WIDGET_DAYS = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far either side of today the month widget pre-loads. The displayed month
|
||||||
|
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
||||||
|
* prev/next navigation without re-querying on every arrow tap.
|
||||||
|
*/
|
||||||
|
private const val MONTH_WIDGET_RANGE_DAYS = 400
|
||||||
|
|
||||||
|
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
internal fun today(zone: TimeZone): LocalDate =
|
||||||
|
Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
internal fun Context.hasCalendarPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Snapshot rendered by the agenda widget. */
|
||||||
|
sealed interface AgendaWidgetData {
|
||||||
|
/** Calendar permission not granted — the widget can't read events. */
|
||||||
|
data object NeedsPermission : AgendaWidgetData
|
||||||
|
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source data for the month widget: a wide window of instances plus the
|
||||||
|
* week-start preference and today. The widget computes each displayed month's
|
||||||
|
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
|
||||||
|
* so month navigation is pure recomposition — no reload, no flaky widget
|
||||||
|
* session restart.
|
||||||
|
*/
|
||||||
|
sealed interface MonthWidgetSource {
|
||||||
|
data object NeedsPermission : MonthWidgetSource
|
||||||
|
data class Ready(
|
||||||
|
val today: LocalDate,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
val instances: List<EventInstance>,
|
||||||
|
) : MonthWidgetSource
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-lived cache of the wide month window. Month navigation re-runs
|
||||||
|
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
|
||||||
|
* every arrow tap is what made paging feel sluggish — so we load once and reuse
|
||||||
|
* the same snapshot for every nearby month. Invalidated by
|
||||||
|
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||||
|
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||||
|
*/
|
||||||
|
internal object MonthWidgetCache {
|
||||||
|
@Volatile
|
||||||
|
var data: MonthWidgetSource.Ready? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun invalidateMonthWidgetCache() {
|
||||||
|
MonthWidgetCache.data = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||||
|
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||||
|
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||||
|
*/
|
||||||
|
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||||
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
|
val zone = systemZone()
|
||||||
|
val anchor = today(zone)
|
||||||
|
val repo = widgetEntryPoint().calendarRepository()
|
||||||
|
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||||
|
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||||
|
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
|
||||||
|
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||||
|
val zone = systemZone()
|
||||||
|
val anchor = today(zone)
|
||||||
|
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||||
|
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
|
||||||
|
val ep = widgetEntryPoint()
|
||||||
|
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
|
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
|
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
|
val instances = ep.calendarRepository().instances(from..to).first()
|
||||||
|
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
|
||||||
|
.also { MonthWidgetCache.data = it }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilt bridge for the Glance widgets. A [androidx.glance.appwidget.GlanceAppWidget]
|
||||||
|
* is instantiated by the framework, not by Hilt, so it can't take constructor
|
||||||
|
* injection. We instead reach the singleton graph through this entry point and
|
||||||
|
* read the same [CalendarRepository] / [SettingsPrefs] the app uses — so widget
|
||||||
|
* data (hidden-calendar filtering, week-start preference, …) matches the app
|
||||||
|
* one-to-one.
|
||||||
|
*/
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface WidgetEntryPoint {
|
||||||
|
fun calendarRepository(): CalendarRepository
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
||||||
|
EntryPointAccessors.fromApplication(applicationContext, WidgetEntryPoint::class.java)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.material3.ColorProviders
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand fallback for devices without Material You dynamic colour (API < 31).
|
||||||
|
* Reuses the exact same hand-tuned schemes as the in-app theme
|
||||||
|
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
|
||||||
|
* device matches the app surface-for-surface.
|
||||||
|
*/
|
||||||
|
private val CalendulaGlanceColors = ColorProviders(
|
||||||
|
light = CalendulaLightFallback,
|
||||||
|
dark = CalendulaDarkFallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
|
||||||
|
* Material You palette (so the widget matches the home screen / the app's
|
||||||
|
* dynamic colour); below that it falls back to the brand scheme. Either way the
|
||||||
|
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
|
||||||
|
* a hardcoded colour — so it tracks light/dark automatically.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||||
|
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
GlanceTheme.colors
|
||||||
|
} else {
|
||||||
|
CalendulaGlanceColors
|
||||||
|
}
|
||||||
|
GlanceTheme(colors = colors, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
||||||
|
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||||
|
* so it covers both the app's own writes and external sync.
|
||||||
|
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
||||||
|
* and the upcoming window roll over at midnight / on a clock change.
|
||||||
|
*
|
||||||
|
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
||||||
|
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||||
|
*/
|
||||||
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val pending = goAsync()
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
|
// month window so the widgets reload fresh. Month paging does NOT call
|
||||||
|
// this, so arrow taps stay instant.
|
||||||
|
invalidateMonthWidgetCache()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
MonthWidget().updateAll(appContext)
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
|
import androidx.glance.appwidget.lazy.items
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
|
*/
|
||||||
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val data = context.loadAgendaWidgetData()
|
||||||
|
val dark = (context.resources.configuration.uiMode and
|
||||||
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
provideContent {
|
||||||
|
CalendulaGlanceTheme {
|
||||||
|
AgendaWidgetBody(data = data, dark = dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
||||||
|
class RefreshAgendaAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
AgendaWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||||
|
private sealed interface AgendaRow {
|
||||||
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
|
data class Event(val event: EventInstance) : AgendaRow
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.surface)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
AgendaHeader()
|
||||||
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
|
when (data) {
|
||||||
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||||
|
is AgendaWidgetData.Ready ->
|
||||||
|
if (data.days.isEmpty()) {
|
||||||
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
|
} else {
|
||||||
|
val rows = buildList {
|
||||||
|
data.days.forEach { day ->
|
||||||
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
|
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
items(rows.size) { index ->
|
||||||
|
when (val row = rows[index]) {
|
||||||
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
|
is AgendaRow.Event -> EventRow(row.event, dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaHeader() {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
resId = R.drawable.ic_widget_refresh,
|
||||||
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
resId = R.drawable.ic_widget_add,
|
||||||
|
contentDescription = context.getString(R.string.widget_new_event),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(resId),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
|
modifier = GlanceModifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(context, date, today),
|
||||||
|
style = TextStyle(
|
||||||
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
||||||
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.eventDetailIntent(
|
||||||
|
context = context,
|
||||||
|
eventId = event.eventId,
|
||||||
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.width(5.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.cornerRadius(3.dp)
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
) {}
|
||||||
|
Spacer(GlanceModifier.width(10.dp))
|
||||||
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventTimeSummary(context, event),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WidgetMessage(resId: Int) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(resId),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zone(): TimeZone = systemZone()
|
||||||
|
|
||||||
|
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||||
|
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> context.getString(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val formatted = "$weekday, ${date.day} $monthName"
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
||||||
|
val time = if (event.isAllDay) {
|
||||||
|
context.getString(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||||
|
}
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(instant: Instant): String {
|
||||||
|
val t = instant.toLocalDateTime(zone()).time
|
||||||
|
return "%02d:%02d".format(t.hour, t.minute)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||||
|
* `appwidget_info_agenda` provider metadata; delegates all rendering to
|
||||||
|
* [AgendaWidget].
|
||||||
|
*/
|
||||||
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalContext
|
||||||
|
import androidx.glance.LocalSize
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.action.actionParametersOf
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import androidx.glance.unit.ColorProvider
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||||
|
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||||
|
|
||||||
|
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||||
|
private const val MAX_LANES = 3
|
||||||
|
private val LANE_HEIGHT = 14.dp
|
||||||
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
|
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
|
||||||
|
private val EventInk = ColorProvider(Color(0xDE000000))
|
||||||
|
|
||||||
|
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||||
|
val t = today(zone)
|
||||||
|
return t.year * 12 + t.month.ordinal
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
|
YearMonth(index / 12, Month(index % 12 + 1))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
|
||||||
|
* event bars and titled single-day pills (the in-app lane layout via
|
||||||
|
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||||
|
*
|
||||||
|
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
||||||
|
* multi-day span renders as a single Box spanning its columns — connected, no
|
||||||
|
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||||
|
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||||
|
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||||
|
*/
|
||||||
|
class MonthWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val source = context.loadMonthWidgetSource()
|
||||||
|
val dark = (context.resources.configuration.uiMode and
|
||||||
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
provideContent {
|
||||||
|
CalendulaGlanceTheme {
|
||||||
|
MonthWidgetBody(source = source, dark = dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step the displayed month by the `delta` action parameter (±1). */
|
||||||
|
class ShiftMonthAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
val delta = parameters[deltaKey] ?: 0
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||||
|
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||||
|
}
|
||||||
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val deltaKey = ActionParameters.Key<Int>("delta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump the displayed month back to the current month. */
|
||||||
|
class ResetMonthAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||||
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.surface)
|
||||||
|
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
when (source) {
|
||||||
|
MonthWidgetSource.NeedsPermission -> {
|
||||||
|
MonthHeader(label = "Calendula")
|
||||||
|
PermissionMessage()
|
||||||
|
}
|
||||||
|
is MonthWidgetSource.Ready -> {
|
||||||
|
val zone = systemZone()
|
||||||
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||||
|
val ym = yearMonthOf(index)
|
||||||
|
// Column width from the live widget size, minus our H padding.
|
||||||
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
|
||||||
|
MonthHeader(label = monthLabel(ym))
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||||
|
weeks.forEach { week ->
|
||||||
|
WeekRow(
|
||||||
|
week = week,
|
||||||
|
currentMonth = ym.month,
|
||||||
|
today = source.today,
|
||||||
|
dark = dark,
|
||||||
|
colW = colW,
|
||||||
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthHeader(label: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_chevron_left,
|
||||||
|
contentDescription = context.getString(R.string.widget_prev_month),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionRunCallback<ShiftMonthAction>(
|
||||||
|
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.defaultWeight()
|
||||||
|
.clickable(actionRunCallback<ResetMonthAction>()),
|
||||||
|
)
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_today,
|
||||||
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_chevron_right,
|
||||||
|
contentDescription = context.getString(R.string.widget_next_month),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionRunCallback<ShiftMonthAction>(
|
||||||
|
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(resId),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
|
modifier = GlanceModifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
weekdayNarrowNames(weekStart).forEach { name ->
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier.width(colW),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||||
|
* Computed outside the composable so the locale read stays observable-safe. */
|
||||||
|
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return (0 until 7).map { i ->
|
||||||
|
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.NARROW, locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekRow(
|
||||||
|
week: MonthWeek,
|
||||||
|
currentMonth: Month,
|
||||||
|
today: LocalDate,
|
||||||
|
dark: Boolean,
|
||||||
|
colW: Dp,
|
||||||
|
modifier: GlanceModifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
// Day numbers.
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
week.days.forEach { date ->
|
||||||
|
DayNumber(
|
||||||
|
date = date,
|
||||||
|
isToday = date == today,
|
||||||
|
inMonth = date.month == currentMonth,
|
||||||
|
colW = colW,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
|
repeat(MAX_LANES) { lane ->
|
||||||
|
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
|
||||||
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
|
}
|
||||||
|
OverflowRow(week = week, colW = colW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
|
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = TextStyle(
|
||||||
|
color = when {
|
||||||
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
|
else -> GlanceTheme.colors.onSurfaceVariant
|
||||||
|
},
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
var col = 0
|
||||||
|
while (col < 7) {
|
||||||
|
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
if (span != null) {
|
||||||
|
val cols = span.endCol - col + 1
|
||||||
|
SpanBar(event = span.event, dark = dark, width = colW * cols)
|
||||||
|
col = span.endCol + 1
|
||||||
|
} else {
|
||||||
|
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||||
|
if (timed != null) {
|
||||||
|
SpanBar(event = timed, dark = dark, width = colW)
|
||||||
|
} else {
|
||||||
|
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
||||||
|
}
|
||||||
|
col += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||||
|
@Composable
|
||||||
|
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.cornerRadius(4.dp)
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = EventInk, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
week.days.forEachIndexed { col, date ->
|
||||||
|
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
|
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||||
|
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (hidden > 0) {
|
||||||
|
Text(
|
||||||
|
text = "+$hidden",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.padding(start = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The timed single-day event that fills lane [lane] on day [col], if any. */
|
||||||
|
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
|
||||||
|
val occupied = week.spans
|
||||||
|
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
.map { it.lane }
|
||||||
|
.toSet()
|
||||||
|
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
|
||||||
|
val timed = week.timedByDay[date].orEmpty()
|
||||||
|
return timed.getOrNull(freeSlots.indexOf(lane))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionMessage() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_needs_permission),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun monthLabel(month: YearMonth): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val name = java.time.Month.of(month.month.ordinal + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
|
return "$name ${month.year}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||||
|
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
|
||||||
|
*/
|
||||||
|
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||||
|
}
|
||||||
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "close" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "snooze" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
|
||||||
|
</vector>
|
||||||
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Quick Settings tile icon ("New event"): a flat calendar+plus glyph. QS tile
|
||||||
|
icons are tinted by the system from their alpha, so this is a bare white
|
||||||
|
glyph on a transparent background (no brand circle, unlike the launcher
|
||||||
|
shortcut icon). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
|
||||||
|
</vector>
|
||||||
26
app/src/main/res/drawable/ic_shortcut_new_event.xml
Normal file
26
app/src/main/res/drawable/ic_shortcut_new_event.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Launcher long-press shortcut icon ("New event"): a brand-coloured circle
|
||||||
|
with a white calendar+plus glyph, as one scalable vector. Self-contained so
|
||||||
|
it stays visible on any launcher surface (shortcut icons aren't tinted, so a
|
||||||
|
bare white glyph would vanish on a light sheet). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3B5364"
|
||||||
|
android:pathData="M0,12 a12,12 0 1,0 24,0 a12,12 0 1,0 -24,0 Z" />
|
||||||
|
<group
|
||||||
|
android:scaleX="0.58"
|
||||||
|
android:scaleY="0.58"
|
||||||
|
android:translateX="5"
|
||||||
|
android:translateY="5">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_add.xml
Normal file
6
app/src/main/res/drawable/ic_widget_add.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_chevron_left.xml
Normal file
6
app/src/main/res/drawable/ic_widget_chevron_left.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_chevron_right.xml
Normal file
6
app/src/main/res/drawable/ic_widget_chevron_right.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
6
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_today.xml
Normal file
6
app/src/main/res/drawable/ic_widget_today.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/preview_stripe.xml
Normal file
6
app/src/main/res/drawable/preview_stripe.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#4F7CAC" />
|
||||||
|
<corners android:radius="3dp" />
|
||||||
|
</shape>
|
||||||
5
app/src/main/res/drawable/preview_today_circle.xml
Normal file
5
app/src/main/res/drawable/preview_today_circle.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/widget_preview_primary" />
|
||||||
|
</shape>
|
||||||
6
app/src/main/res/drawable/preview_widget_bg.xml
Normal file
6
app/src/main/res/drawable/preview_widget_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/widget_preview_surface" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
</shape>
|
||||||
124
app/src/main/res/layout/widget_preview_agenda.xml
Normal file
124
app/src/main/res/layout/widget_preview_agenda.xml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker (android:previewLayout). Not the
|
||||||
|
runtime layout — the live widget is rendered by Glance. Colours are the
|
||||||
|
brand light scheme so the picker preview reads on its light sheet. -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/preview_widget_bg"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/widget_agenda_title"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/agenda_header_today"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#4F7CAC" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Standup"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="09:00 – 09:30"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#6FA37A" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Design review"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="11:30 – 12:30"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#C58A56" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="1:1 with Mara"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="14:00 – 14:30"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
114
app/src/main/res/layout/widget_preview_month.xml
Normal file
114
app/src/main/res/layout/widget_preview_month.xml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker (android:previewLayout). The live
|
||||||
|
widget is rendered by Glance with real events; this only needs to read as
|
||||||
|
"a month grid" in the picker. -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/preview_widget_bg"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="‹"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="June 2026"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="›"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Weekday header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="M" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="W" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="F" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 1: 1..7 (event bar on the 3rd) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="#4F7CAC" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="6" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="7" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 2: 8..14 (bars on 11,12,13) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="8" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="9" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="10" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="11" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="12" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="13" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="14" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 3: 15..21 (17 = today) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="15" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="16" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:orientation="horizontal">
|
||||||
|
<TextView android:layout_width="22dp" android:layout_height="22dp" android:gravity="center"
|
||||||
|
android:text="17" android:textColor="@color/widget_preview_on_primary" android:textStyle="bold" android:textSize="12sp"
|
||||||
|
android:background="@drawable/preview_today_circle" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="18" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="19" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="20" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="21" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 4: 22..28 (bar on 24) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="22" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="23" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="24" android:textColor="#C58A56" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="25" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="26" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="27" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="28" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 5: 29, 30, then trailing July days greyed -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="29" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="30" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user