Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 779fa1d480 | |||
| c59a071b82 | |||
| 285bfd90a7 | |||
| 9529f19c60 | |||
| 0013c9f3b1 | |||
| bd6ad4ae5f | |||
| 3697a58e5b | |||
| e290c92d78 | |||
| 9c4ebbc65a | |||
| c0d413ba11 | |||
| dca0245a42 | |||
| 024512959f | |||
| e78da3d7c1 | |||
| 2cb8b59fb7 |
@@ -6,7 +6,11 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- '**'
|
- '**'
|
||||||
pull_request:
|
|
||||||
|
# Cancel superseded runs on the same branch.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -26,30 +30,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
|
# (~300 MB) which the build never uses.
|
||||||
|
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
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
- name: Install jq
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
SUDO=""
|
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
$SUDO apt-get update
|
|
||||||
$SUDO apt-get install -y jq
|
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
|
||||||
$SUDO apk add --no-cache jq
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
- name: Setup Gradle cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -63,16 +62,19 @@ jobs:
|
|||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
- name: Lint (debug variant only)
|
- name: Lint (debug variant only)
|
||||||
run: ./gradlew lintDebug --no-daemon
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest --no-daemon
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
run: ./gradlew assembleDebug --no-daemon
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Trivy filesystem scan
|
- name: Trivy filesystem scan
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|||||||
@@ -24,16 +24,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
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
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Setup Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
@@ -42,10 +59,10 @@ jobs:
|
|||||||
# any tag-resolved drift (e.g. version code substitution issues).
|
# any tag-resolved drift (e.g. version code substitution issues).
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest --no-daemon
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug APK (sanity)
|
- name: Assemble debug APK (sanity)
|
||||||
run: ./gradlew assembleDebug --no-daemon
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
needs: ci
|
needs: ci
|
||||||
@@ -65,16 +82,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
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
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
sdkmanager \
|
sdkmanager \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-36" \
|
|
||||||
"platforms;android-37.0" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Setup Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Install jq
|
- name: Install jq
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -121,7 +155,7 @@ jobs:
|
|||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
run: ./gradlew assembleRelease --no-daemon
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Setup F-Droid Server Tools
|
- name: Setup F-Droid Server Tools
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
|||||||
- [ ] Day view (S3)
|
- [ ] Day view (S3)
|
||||||
- [ ] Event Detail Sheet (S4)
|
- [ ] Event Detail Sheet (S4)
|
||||||
- [ ] Multi-Calendar Filter (M3)
|
- [ ] Multi-Calendar Filter (M3)
|
||||||
- [ ] Today button + Jump-to-Date (M2)
|
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
||||||
- [ ] View-Switcher (M1)
|
- [ ] View-Switcher (M1)
|
||||||
- [ ] Settings screen (M4)
|
- [ ] Settings screen (M4)
|
||||||
- [ ] Empty / no-permission / no-calendars states
|
- [ ] Empty / no-permission / no-calendars states
|
||||||
|
|||||||
@@ -9,22 +9,62 @@
|
|||||||
| v0.3 | Month + Week + Day views, view switcher | complete |
|
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||||
| v1.0 | Polish + jump-to-date (M2), F-Droid release | pending |
|
| v0.6 | Full event read — surface every readable field | complete |
|
||||||
|
| v1.0 | First public release — polish pass, F-Droid | complete |
|
||||||
|
|
||||||
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||||
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||||
Jump-to-date (M2) was deferred out of v0.5 and folds into the v1.0 polish pass.
|
|
||||||
|
|
||||||
## v1.0 — First Public Release
|
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
|
||||||
|
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
|
||||||
|
|
||||||
All V1 features shipped, polished, on F-Droid. Read-only calendar.
|
## v0.6 — Full event read
|
||||||
Remaining before v1.0: jump-to-date (M2) and a UI polish/QA pass.
|
|
||||||
|
|
||||||
## v2.0 — Write Support
|
Round out the read-only model so a detail view shows everything the system
|
||||||
|
actually stores, before write support starts. Scope = `CalendarContract`
|
||||||
|
columns we don't yet read/display:
|
||||||
|
|
||||||
- Event create / edit / delete via `CalendarContract` writes
|
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
|
||||||
- Quick-add sheet
|
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
|
||||||
- Conflict UX (event modified externally during edit)
|
- **Availability** (`TRANSP`) — Free / Busy chip
|
||||||
|
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
||||||
|
`SELF_ATTENDEE_STATUS`
|
||||||
|
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
||||||
|
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
||||||
|
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
||||||
|
URLs are instead surfaced by linkifying the description text
|
||||||
|
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
||||||
|
|
||||||
|
All of the above shipped in v0.6.0 (2026-06-11).
|
||||||
|
|
||||||
|
Deliberately out of v0.6:
|
||||||
|
- Recurrence exception / modified-occurrence badges — `Instances` already
|
||||||
|
resolves correct per-occurrence times for display; this only matters for
|
||||||
|
editing, so it folds into v2
|
||||||
|
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
||||||
|
(provider limitation, not our choice)
|
||||||
|
|
||||||
|
## v1.0 — First Public Release — shipped 2026-06-11
|
||||||
|
|
||||||
|
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
|
||||||
|
after v0.6 (full event read) plus the onboarding-screen polish pass.
|
||||||
|
|
||||||
|
### Polish backlog (pre-1.0)
|
||||||
|
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||||
|
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||||
|
|
||||||
|
## v2.0 — Write Support (in progress)
|
||||||
|
|
||||||
|
Delivered in four releasable slices (plan:
|
||||||
|
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||||
|
guide here, not a contract — scope per slice is decided as we go.
|
||||||
|
|
||||||
|
| Version | Milestone | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
||||||
|
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||||
|
| v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned |
|
||||||
|
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
||||||
|
|
||||||
## v3.0 — Power-User Features
|
## v3.0 — Power-User Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-10*
|
*Last updated: 2026-06-11*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** v0.5 — Calendar filter (M3) + Settings (M4) (complete)
|
**Milestone:** v2.0 — Write support (milestone 2, in progress)
|
||||||
**Phase:** All V1 screens and cross-cutting wiring done except jump-to-date
|
**Phase:** v1.2.0 shipped 2026-06-11 (create event), after v1.1.0 the same
|
||||||
(M2), which is deferred to the v1.0 polish pass
|
day (write foundation + delete). Milestone 2 runs in four slices
|
||||||
|
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3
|
||||||
|
(edit event).
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -21,10 +23,28 @@
|
|||||||
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
||||||
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
||||||
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
||||||
- [ ] Jump-to-date (M2) — drawer entry still stubbed (deferred to v1.0)
|
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
||||||
|
- [x] Full event read (v0.6) — reminders, status, availability, access level,
|
||||||
|
attendee role + self-response, foreign timezone, and linkified description
|
||||||
|
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
||||||
|
URL field was cut — no `CalendarContract` column backs it.)
|
||||||
|
|
||||||
|
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
|
||||||
|
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
|
||||||
|
detection (`CALENDAR_ACCESS_LEVEL` → `canModifyContents`, actions hidden for
|
||||||
|
WebCal/birthday calendars), delete from the detail screen (recurring:
|
||||||
|
"only this event" via cancelled exception / "all events in the series"),
|
||||||
|
repository + mapper tests
|
||||||
|
|
||||||
|
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
|
||||||
|
M3 date/time pickers with duration-preserving start moves, writable-only
|
||||||
|
calendar picker preselecting the last-used calendar, location, description),
|
||||||
|
"+" FAB on all three views prefilled with the visible day, `insertEvent`
|
||||||
|
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
||||||
|
domain/mapper/repository tests
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Jump-to-date (M2) — date picker from the drawer, reachable on every view
|
1. v1.3 — edit event: reuse the form, series edit, reminder edit, simple
|
||||||
2. UI polish / QA pass across all views before v1.0
|
recurrence picker
|
||||||
3. F-Droid release of v1.0
|
2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0
|
||||||
|
|||||||
106
CHANGELOG.md
106
CHANGELOG.md
@@ -7,6 +7,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Create events (milestone 2, slice 2):
|
||||||
|
- A "+" FAB on the month, week, and day views opens a new full-screen event
|
||||||
|
form, prefilled with the visible day (today at the next full hour, or
|
||||||
|
09:00 on other days)
|
||||||
|
- The form covers title, all-day toggle, start/end with Material 3 date and
|
||||||
|
time pickers (moving the start drags the end along, preserving duration),
|
||||||
|
target calendar, location, and description
|
||||||
|
- The calendar picker offers only writable calendars and preselects the one
|
||||||
|
you last created an event in
|
||||||
|
- Validation on save ("ends before it starts", no writable calendar), with
|
||||||
|
the same contextual write-permission upgrade as delete
|
||||||
|
- All-day events are stored provider-correctly (UTC midnights, exclusive
|
||||||
|
end), timed events in the device time zone
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The jump-to-today pill now stacks above the new "+" FAB instead of being
|
||||||
|
the only floating action
|
||||||
|
- `versionName`/`versionCode` bumped to 1.2.0 / 9
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Write foundation (milestone 2, slice 1): Calendula can now **delete events**.
|
||||||
|
- Delete action on the event detail screen, with a confirmation dialog;
|
||||||
|
recurring events choose between "Only this event" (a cancelled exception,
|
||||||
|
so the rest of the series survives) and "All events in the series"
|
||||||
|
- `WRITE_CALENDAR` permission: onboarding asks for read+write in one system
|
||||||
|
dialog, but only read access is required — declining write keeps the app
|
||||||
|
fully usable read-only. Existing v1.0 installs are asked for the write
|
||||||
|
upgrade in place, on their first delete
|
||||||
|
- Read-only calendars (WebCal subscriptions, birthday calendars, …) are
|
||||||
|
detected via `CALENDAR_ACCESS_LEVEL` and show no edit/delete actions at all
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Onboarding copy no longer claims "read-only"; it now says your data stays on
|
||||||
|
the device (still no internet permission, still zero telemetry)
|
||||||
|
- The placeholder Edit button on the detail screen (a no-op since v0.4) is
|
||||||
|
removed until editing ships in a later slice
|
||||||
|
- `versionName`/`versionCode` bumped to 1.1.0 / 8
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-06-11
|
||||||
|
|
||||||
|
First public release. Calendula is a read-only, Material 3 Expressive calendar
|
||||||
|
that lives entirely on top of Android's `CalendarContract` — every calendar
|
||||||
|
synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up
|
||||||
|
automatically, with zero telemetry and no internet permission.
|
||||||
|
|
||||||
|
### Highlights (accumulated across v0.1 → v0.6)
|
||||||
|
- Month, week, and day views with a view switcher, swipe navigation, and
|
||||||
|
Loading / Failure / Success states on every screen
|
||||||
|
- Full-screen event detail surfacing every readable `CalendarContract` field —
|
||||||
|
times, recurrence (humanised), location, description (with tappable links),
|
||||||
|
attendees + roles + your own response, reminders, status, availability,
|
||||||
|
access level, and foreign time zones
|
||||||
|
- Per-calendar visibility filter (grouped by account, persisted) and a Settings
|
||||||
|
screen (theme, Material You dynamic colour, week start, app language)
|
||||||
|
- Material 3 Expressive first-run onboarding for calendar access
|
||||||
|
- German + English localization throughout
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `versionName`/`versionCode` bumped to 1.0.0 / 7
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Full event read (v0.6): the detail screen now surfaces every readable
|
||||||
|
`CalendarContract` field that V1 had been dropping —
|
||||||
|
- **Reminders** — each configured lead time, humanised ("10 minutes before",
|
||||||
|
"1 day before", "At time of event"), read from `CalendarContract.Reminders`
|
||||||
|
- **Status** — Tentative / Cancelled chip under the title; a cancelled event
|
||||||
|
also strikes through its title (Confirmed shows no chip)
|
||||||
|
- **Availability** — a "Free" pill pinned top-right of the title when the
|
||||||
|
event doesn't block your time (`Events.AVAILABILITY`, the iCal TRANSP
|
||||||
|
field); the default "Busy" is left implicit to avoid noise on every event
|
||||||
|
- **Access level** — a Private / Confidential chip when the event isn't public
|
||||||
|
- **Attendee role** — organizer / optional / resource badge under each
|
||||||
|
attendee, plus the device user's own response ("Your response: …") from
|
||||||
|
`Events.SELF_ATTENDEE_STATUS`
|
||||||
|
- **Time zone** — shown only for timed events pinned to a zone other than the
|
||||||
|
device's, so cross-zone events read unambiguously
|
||||||
|
- **Linked URLs** — http(s) links in the description are now tappable
|
||||||
|
- Domain model rounded out with `Reminder`, `EventStatus`, `Availability`,
|
||||||
|
`AccessLevel`, `AttendeeRelationship`, `AttendeeType`, and the attendee/self
|
||||||
|
status fields; mappers + unit tests cover every new column's integer codes
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned the first-run grant-access screen — the onboarding a new user
|
||||||
|
sees. Material 3 Expressive layout: branded launcher-mark hero, an app-name
|
||||||
|
eyebrow, a benefit-led headline, three trust rows (on-device, every calendar,
|
||||||
|
no tracking) with tonal icon chips, a full-width filled CTA with a trailing
|
||||||
|
arrow, and a "Read-only · no internet permission" footnote (the app declares
|
||||||
|
only `READ_CALENDAR`). The denied/recovery state shares the same shell with a
|
||||||
|
lock-badged hero and Open-settings / Try-again actions
|
||||||
|
- `versionName`/`versionCode` bumped to 0.6.0 / 6
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- A dedicated event **URL** field was dropped from scope: `CalendarContract`
|
||||||
|
has no `Events.URL` column (only `CUSTOM_APP_URI`, an app deep-link), so URLs
|
||||||
|
are surfaced by linkifying the description instead
|
||||||
|
|
||||||
## [0.5.0] — 2026-06-10
|
## [0.5.0] — 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -16,7 +119,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
separate from the system VISIBLE flag) and applied centrally in the
|
separate from the system VISIBLE flag) and applied centrally in the
|
||||||
repository, so month/week/day re-filter live the moment a switch flips.
|
repository, so month/week/day re-filter live the moment a switch flips.
|
||||||
The drawer was trimmed to just Today, the calendar filter, and Settings
|
The drawer was trimmed to just Today, the calendar filter, and Settings
|
||||||
(the stubbed jump-to-date entry was removed; M2 returns in v1.0)
|
(the stubbed jump-to-date entry was removed; jump-to-date was later cut
|
||||||
|
from scope entirely)
|
||||||
- Settings (M4): a full-screen destination with
|
- Settings (M4): a full-screen destination with
|
||||||
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
|
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
|
||||||
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
|
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 5
|
versionCode = 9
|
||||||
versionName = "0.5.0"
|
versionName = "1.2.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
@@ -12,7 +13,10 @@ 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.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -28,6 +32,19 @@ interface CalendarDataSource {
|
|||||||
fun calendars(): List<CalendarSource>
|
fun calendars(): List<CalendarSource>
|
||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/** Insert a new event; returns the new `Events._ID`. */
|
||||||
|
fun insertEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
|
fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a single occurrence of a recurring event by inserting a
|
||||||
|
* cancelled exception at [beginMillis] (the occurrence's `Instances.BEGIN`).
|
||||||
|
*/
|
||||||
|
fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||||
|
|
||||||
fun registerChangeListener(listener: () -> Unit)
|
fun registerChangeListener(listener: () -> Unit)
|
||||||
fun unregisterChangeListener(listener: () -> Unit)
|
fun unregisterChangeListener(listener: () -> Unit)
|
||||||
}
|
}
|
||||||
@@ -62,16 +79,61 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
EventDetailProjection.COLUMNS,
|
EventDetailProjection.COLUMNS,
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(attendees)
|
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm): Long {
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
|
return ContentUris.parseId(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEvent(eventId: Long) {
|
||||||
|
val deleted = resolver.delete(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||||
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
val uri = ContentUris.withAppendedId(
|
||||||
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
|
)
|
||||||
|
resolver.insert(uri, values)
|
||||||
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
|
||||||
override fun registerChangeListener(listener: () -> Unit) {
|
override fun registerChangeListener(listener: () -> Unit) {
|
||||||
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean) {
|
override fun onChange(selfChange: Boolean) {
|
||||||
@@ -98,6 +160,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null,
|
null,
|
||||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
||||||
|
|
||||||
|
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
ReminderProjection.COLUMNS,
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
|
||||||
|
|
||||||
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
||||||
|
|
||||||
/** Iterate every row and map; skips nothing. */
|
/** Iterate every row and map; skips nothing. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
|
||||||
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
||||||
@@ -10,4 +11,6 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
|||||||
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
|
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
|
||||||
color = getInt(CalendarProjection.IDX_COLOR),
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
|
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
|
||||||
|
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -10,7 +11,20 @@ interface CalendarRepository {
|
|||||||
fun calendars(): Flow<List<CalendarSource>>
|
fun calendars(): Flow<List<CalendarSource>>
|
||||||
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
|
||||||
|
|
||||||
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
|
suspend fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
|
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
|
||||||
|
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoSuchEventException(eventId: Long) :
|
class NoSuchEventException(eventId: Long) :
|
||||||
NoSuchElementException("No event with id=$eventId")
|
NoSuchElementException("No event with id=$eventId")
|
||||||
|
|
||||||
|
/** A ContentResolver write affected no rows or returned no URI. */
|
||||||
|
class WriteFailedException(operation: String) :
|
||||||
|
RuntimeException("Calendar write failed: $operation")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -68,6 +69,18 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
|
dataSource.insertEvent(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
|
dataSource.deleteEvent(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
|
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||||
|
|||||||
@@ -2,14 +2,24 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
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.ReminderMethod
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
|
attendees: List<Attendee>,
|
||||||
|
reminders: List<Reminder>,
|
||||||
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
if (begin < 0L) {
|
if (begin < 0L) {
|
||||||
@@ -54,12 +64,28 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
|
|||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
return EventDetail(
|
return EventDetail(
|
||||||
instance = instance,
|
instance = instance,
|
||||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||||
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,
|
||||||
|
status = status,
|
||||||
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
|
// default these mappers already return — no isNull guard needed.
|
||||||
|
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
||||||
|
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||||
|
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +93,13 @@ internal fun ColumnReader.toAttendee(): Attendee = Attendee(
|
|||||||
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
||||||
email = getString(AttendeeProjection.IDX_EMAIL),
|
email = getString(AttendeeProjection.IDX_EMAIL),
|
||||||
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
||||||
|
relationship = mapAttendeeRelationship(getInt(AttendeeProjection.IDX_RELATIONSHIP)),
|
||||||
|
type = mapAttendeeType(getInt(AttendeeProjection.IDX_TYPE)),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun ColumnReader.toReminder(): Reminder = Reminder(
|
||||||
|
minutes = getInt(ReminderProjection.IDX_MINUTES),
|
||||||
|
method = mapReminderMethod(getInt(ReminderProjection.IDX_METHOD)),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
||||||
@@ -76,3 +109,46 @@ internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
|||||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
||||||
else -> AttendeeStatus.Unknown
|
else -> AttendeeStatus.Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeRelationship(raw: Int): AttendeeRelationship = when (raw) {
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ORGANIZER -> AttendeeRelationship.Organizer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> AttendeeRelationship.Performer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_SPEAKER -> AttendeeRelationship.Speaker
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE -> AttendeeRelationship.Attendee
|
||||||
|
else -> AttendeeRelationship.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeType(raw: Int): AttendeeType = when (raw) {
|
||||||
|
CalendarContract.Attendees.TYPE_REQUIRED -> AttendeeType.Required
|
||||||
|
CalendarContract.Attendees.TYPE_OPTIONAL -> AttendeeType.Optional
|
||||||
|
CalendarContract.Attendees.TYPE_RESOURCE -> AttendeeType.Resource
|
||||||
|
else -> AttendeeType.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapEventStatus(raw: Int): EventStatus = when (raw) {
|
||||||
|
CalendarContract.Events.STATUS_CONFIRMED -> EventStatus.Confirmed
|
||||||
|
CalendarContract.Events.STATUS_TENTATIVE -> EventStatus.Tentative
|
||||||
|
CalendarContract.Events.STATUS_CANCELED -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAvailability(raw: Int): Availability = when (raw) {
|
||||||
|
CalendarContract.Events.AVAILABILITY_FREE -> Availability.Free
|
||||||
|
CalendarContract.Events.AVAILABILITY_TENTATIVE -> Availability.Tentative
|
||||||
|
else -> Availability.Busy
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAccessLevel(raw: Int): AccessLevel = when (raw) {
|
||||||
|
CalendarContract.Events.ACCESS_CONFIDENTIAL -> AccessLevel.Confidential
|
||||||
|
CalendarContract.Events.ACCESS_PRIVATE -> AccessLevel.Private
|
||||||
|
CalendarContract.Events.ACCESS_PUBLIC -> AccessLevel.Public
|
||||||
|
else -> AccessLevel.Default
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapReminderMethod(raw: Int): ReminderMethod = when (raw) {
|
||||||
|
CalendarContract.Reminders.METHOD_EMAIL -> ReminderMethod.Email
|
||||||
|
CalendarContract.Reminders.METHOD_SMS -> ReminderMethod.Sms
|
||||||
|
CalendarContract.Reminders.METHOD_ALARM -> ReminderMethod.Alarm
|
||||||
|
CalendarContract.Reminders.METHOD_ALERT -> ReminderMethod.Alert
|
||||||
|
else -> ReminderMethod.Default
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
|
internal data class EventWriteTimes(
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long,
|
||||||
|
val timezone: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
|
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||||
|
* timed events resolve their wall-clock values in [zone].
|
||||||
|
*/
|
||||||
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
|
EventWriteTimes(
|
||||||
|
dtStartMillis = start.date.toJavaLocalDate()
|
||||||
|
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||||
|
dtEndMillis = end.date.toJavaLocalDate().plusDays(1)
|
||||||
|
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||||
|
timezone = "UTC",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EventWriteTimes(
|
||||||
|
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||||
|
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||||
|
timezone = zone.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ internal object CalendarProjection {
|
|||||||
CalendarContract.Calendars.ACCOUNT_TYPE,
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||||
CalendarContract.Calendars.VISIBLE,
|
CalendarContract.Calendars.VISIBLE,
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -18,6 +19,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_ACCOUNT_TYPE = 3
|
const val IDX_ACCOUNT_TYPE = 3
|
||||||
const val IDX_COLOR = 4
|
const val IDX_COLOR = 4
|
||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -60,6 +62,11 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -74,6 +81,11 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ALL_DAY = 9
|
const val IDX_ALL_DAY = 9
|
||||||
const val IDX_LOCATION = 10
|
const val IDX_LOCATION = 10
|
||||||
const val IDX_CALENDAR_ID = 11
|
const val IDX_CALENDAR_ID = 11
|
||||||
|
const val IDX_STATUS = 12
|
||||||
|
const val IDX_AVAILABILITY = 13
|
||||||
|
const val IDX_ACCESS_LEVEL = 14
|
||||||
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
@@ -81,11 +93,25 @@ internal object AttendeeProjection {
|
|||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_NAME = 0
|
const val IDX_NAME = 0
|
||||||
const val IDX_EMAIL = 1
|
const val IDX_EMAIL = 1
|
||||||
const val IDX_STATUS = 2
|
const val IDX_STATUS = 2
|
||||||
|
const val IDX_RELATIONSHIP = 3
|
||||||
|
const val IDX_TYPE = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ReminderProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Reminders.MINUTES,
|
||||||
|
CalendarContract.Reminders.METHOD,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_MINUTES = 0
|
||||||
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object Fallbacks {
|
internal object Fallbacks {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.prefs
|
|||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -38,7 +39,20 @@ class CalendarPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar the user last created an event in; preselected in the
|
||||||
|
* event form. Null until the first event is created.
|
||||||
|
*/
|
||||||
|
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
|
||||||
|
prefs[LAST_USED_CALENDAR_KEY]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setLastUsedCalendarId(id: Long) {
|
||||||
|
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||||
|
* wall-clock values in the device zone; the data layer translates them to
|
||||||
|
* provider millis (all-day events normalise to UTC midnights there).
|
||||||
|
*/
|
||||||
|
data class EventForm(
|
||||||
|
val calendarId: Long?,
|
||||||
|
val title: String = "",
|
||||||
|
val isAllDay: Boolean = false,
|
||||||
|
val start: LocalDateTime,
|
||||||
|
val end: LocalDateTime,
|
||||||
|
val location: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class EventFormProblem {
|
||||||
|
/** No target calendar — none picked and no writable calendar exists. */
|
||||||
|
NoCalendar,
|
||||||
|
EndBeforeStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation; an empty set means the form can be saved. A blank title is
|
||||||
|
* allowed (display falls back to "(No title)", matching the provider), and a
|
||||||
|
* zero-length timed event is allowed (spec §8: instant events exist).
|
||||||
|
*/
|
||||||
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||||
|
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||||
|
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ data class CalendarSource(
|
|||||||
val accountType: String,
|
val accountType: String,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val isVisibleInSystem: Boolean,
|
val isVisibleInSystem: Boolean,
|
||||||
|
/**
|
||||||
|
* Whether events in this calendar can be created/edited/deleted
|
||||||
|
* (`Calendars.CALENDAR_ACCESS_LEVEL` >= contributor). False for WebCal
|
||||||
|
* subscriptions, birthday calendars and other read-only sources.
|
||||||
|
*/
|
||||||
|
val canModifyContents: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -29,12 +35,34 @@ data class EventDetail(
|
|||||||
val organizer: String?,
|
val organizer: String?,
|
||||||
val attendees: List<Attendee>,
|
val attendees: List<Attendee>,
|
||||||
val rrule: String?,
|
val rrule: String?,
|
||||||
|
/** Reminders (VALARM) configured on the event, ascending lead time. */
|
||||||
|
val reminders: List<Reminder> = emptyList(),
|
||||||
|
/** Confirmed / Tentative / Cancelled (`Events.STATUS`). */
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
/** Busy / Free (`Events.AVAILABILITY`, the iCal TRANSP field). */
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Default / Private / Confidential / Public (`Events.ACCESS_LEVEL`). */
|
||||||
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
/** Raw zone id (`Events.EVENT_TIMEZONE`), e.g. "Europe/Berlin"; null if unset. */
|
||||||
|
val eventTimezone: String? = null,
|
||||||
|
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||||
|
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Attendee(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
val status: AttendeeStatus,
|
val status: AttendeeStatus,
|
||||||
|
/** Organizer / performer / speaker / plain attendee (`ATTENDEE_RELATIONSHIP`). */
|
||||||
|
val relationship: AttendeeRelationship = AttendeeRelationship.None,
|
||||||
|
/** Required / optional / resource (`ATTENDEE_TYPE`). */
|
||||||
|
val type: AttendeeType = AttendeeType.None,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Reminder(
|
||||||
|
/** Lead time before the event start, in minutes. `-1` means the provider default. */
|
||||||
|
val minutes: Int,
|
||||||
|
val method: ReminderMethod,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class AttendeeStatus {
|
enum class AttendeeStatus {
|
||||||
@@ -45,6 +73,48 @@ enum class AttendeeStatus {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class AttendeeRelationship {
|
||||||
|
Organizer,
|
||||||
|
Attendee,
|
||||||
|
Performer,
|
||||||
|
Speaker,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AttendeeType {
|
||||||
|
Required,
|
||||||
|
Optional,
|
||||||
|
Resource,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ReminderMethod {
|
||||||
|
Alert,
|
||||||
|
Email,
|
||||||
|
Sms,
|
||||||
|
Alarm,
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventStatus {
|
||||||
|
Confirmed,
|
||||||
|
Tentative,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Availability {
|
||||||
|
Busy,
|
||||||
|
Free,
|
||||||
|
Tentative,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AccessLevel {
|
||||||
|
Default,
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
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
|
||||||
@@ -66,6 +67,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
val onOpenSettings = { showSettings = true }
|
val onOpenSettings = { showSettings = true }
|
||||||
|
|
||||||
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
|
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||||
|
val onCreateEvent: (LocalDate) -> Unit = { date ->
|
||||||
|
heldCreateIso = date.toString()
|
||||||
|
createDateIso = date.toString()
|
||||||
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -75,12 +85,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
@@ -88,6 +100,7 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +121,20 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = createDateIso != null,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = iso,
|
||||||
|
onClose = { createDateIso = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings (M4) — full-screen destination, slides over the calendar.
|
// Settings (M4) — full-screen destination, slides over the calendar.
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSettings,
|
visible = showSettings,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FAB stack shared by the three calendar views: a persistent "+" to
|
||||||
|
* create an event, with the jump-to-today pill appearing above it whenever
|
||||||
|
* the view isn't anchored on today.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarFabColumn(
|
||||||
|
todayVisible: Boolean,
|
||||||
|
todayText: String,
|
||||||
|
onToday: () -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = todayVisible,
|
||||||
|
enter = scaleIn(),
|
||||||
|
exit = scaleOut(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = onToday,
|
||||||
|
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
|
text = { Text(todayText) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FloatingActionButton(onClick = onCreate) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_new_title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
@@ -28,12 +25,10 @@ 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.Refresh
|
|
||||||
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
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -72,6 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -108,6 +104,7 @@ fun DayScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -172,17 +169,12 @@ fun DayScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnToday,
|
todayVisible = !isOnToday,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = { onCreateEvent(date) },
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = jumpToToday,
|
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
|
||||||
text = { Text(stringResource(R.string.day_today_action)) },
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
DayContent(
|
DayContent(
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.icu.text.ListFormatter
|
import android.icu.text.ListFormatter
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
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.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -13,6 +17,8 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -22,6 +28,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
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
|
||||||
@@ -29,42 +36,64 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
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.Notifications
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
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.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.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
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.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
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.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
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
|
||||||
@@ -79,10 +108,10 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only full-screen event detail (spec S4, realised as a navigation
|
* Full-screen event detail (spec S4, realised as a navigation destination
|
||||||
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
|
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
|
||||||
* gesture and the top-bar arrow both return to the calendar. The only action is
|
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||||
* tapping the location to open a maps intent.
|
* be deleted from here (v1.1); edit follows in v1.3.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -97,10 +126,55 @@ fun EventDetailScreen(
|
|||||||
viewModel.open(eventId, beginMillis, endMillis)
|
viewModel.open(eventId, beginMillis, endMillis)
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
|
// upgrade in place. Granting continues straight into the confirm dialog.
|
||||||
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) showDeleteDialog = true
|
||||||
|
}
|
||||||
|
val onDeleteClick = {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
showDeleteDialog = true
|
||||||
|
} else {
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
|
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||||
|
LaunchedEffect(deleteState) {
|
||||||
|
when (deleteState) {
|
||||||
|
DeleteUiState.Deleted -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
DeleteUiState.Failed -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
snackbarHostState.showSnackbar(deleteFailedMessage)
|
||||||
|
}
|
||||||
|
DeleteUiState.NeedsPermission -> {
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
|
}
|
||||||
|
DeleteUiState.Idle, DeleteUiState.Deleting -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
@@ -113,18 +187,20 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
|
// Only writable calendars get actions — WebCal subscriptions,
|
||||||
Icon(
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
imageVector = Icons.Default.Edit,
|
val s = state
|
||||||
contentDescription = stringResource(R.string.event_detail_edit),
|
if (s is EventDetailUiState.Success && s.canModify) {
|
||||||
)
|
IconButton(
|
||||||
}
|
onClick = onDeleteClick,
|
||||||
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Delete,
|
imageVector = Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
@@ -144,6 +220,88 @@ fun EventDetailScreen(
|
|||||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val loaded = state
|
||||||
|
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
|
||||||
|
DeleteEventDialog(
|
||||||
|
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
|
||||||
|
onConfirm = { wholeSeries ->
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.delete(wholeSeries)
|
||||||
|
},
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete confirmation. Recurring events choose between cancelling just the
|
||||||
|
* tapped occurrence (default) and removing the whole series.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeleteEventDialog(
|
||||||
|
isRecurring: Boolean,
|
||||||
|
onConfirm: (wholeSeries: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var wholeSeries by rememberSaveable { mutableStateOf(false) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isRecurring) R.string.event_delete_recurring_title
|
||||||
|
else R.string.event_delete_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
if (isRecurring) {
|
||||||
|
Column {
|
||||||
|
DeleteChoiceRow(
|
||||||
|
selected = !wholeSeries,
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onSelect = { wholeSeries = false },
|
||||||
|
)
|
||||||
|
DeleteChoiceRow(
|
||||||
|
selected = wholeSeries,
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onSelect = { wholeSeries = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.event_delete_body))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteChoiceRow(selected: Boolean, label: String, onSelect: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(selected = selected, role = Role.RadioButton, onClick = onSelect)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected, onClick = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -159,12 +317,30 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
// Title with a short accent line in the calendar colour underneath.
|
// Title row: title on the left, a "Free" pill pinned top-right when the
|
||||||
|
// event doesn't block your time. Busy is the default for nearly every
|
||||||
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
|
// cancelled event strikes through its title.
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||||
|
TextDecoration.LineThrough
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (detail.availability == Availability.Free) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
InfoChip(
|
||||||
|
text = stringResource(R.string.event_availability_free),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -173,6 +349,16 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
.background(accent, RoundedCornerShape(2.dp)),
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Status / access chips — shown only when noteworthy (Confirmed status
|
||||||
|
// and Default/Public access are the silent norm).
|
||||||
|
val hasStatusChips = detail.status != EventStatus.Confirmed ||
|
||||||
|
detail.accessLevel == AccessLevel.Private ||
|
||||||
|
detail.accessLevel == AccessLevel.Confidential
|
||||||
|
if (hasStatusChips) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
StatusChips(detail.status, detail.accessLevel)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
// Every piece of info shares one card design: a tonal container with a
|
// Every piece of info shares one card design: a tonal container with a
|
||||||
@@ -194,6 +380,18 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
|
// than the device's, so cross-zone events read unambiguously.
|
||||||
|
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Public,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
|
) {
|
||||||
|
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||||
// separate colour dot is needed.
|
// separate colour dot is needed.
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -228,28 +426,63 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description (conditional).
|
// Description (conditional). URLs are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||||
) {
|
) {
|
||||||
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
Text(
|
||||||
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attendees (conditional).
|
// Attendees (conditional). The user's own response leads the list, then
|
||||||
|
// each attendee with their role and reply.
|
||||||
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.People,
|
icon = Icons.Default.People,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||||
) {
|
) {
|
||||||
|
if (detail.selfStatus != AttendeeStatus.Unknown) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_detail_self_response,
|
||||||
|
stringResource(attendeeStatusLabel(detail.selfStatus)),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
attendees.forEach { AttendeeRow(it) }
|
attendees.forEach { AttendeeRow(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reminders (conditional) — list each lead time before the event.
|
||||||
|
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Notifications,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||||
|
) {
|
||||||
|
reminders
|
||||||
|
.distinctBy { it.minutes }
|
||||||
|
.sortedBy { it.minutes }
|
||||||
|
.forEach { reminder ->
|
||||||
|
Text(
|
||||||
|
text = reminderLeadText(reminder),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recurrence (conditional) — humanised from the RRULE.
|
// Recurrence (conditional) — humanised from the RRULE.
|
||||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -304,10 +537,20 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
|
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(roleRes),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(attendeeStatusLabel(attendee.status)),
|
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -316,6 +559,54 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Status / access pills shown directly under the title accent. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun StatusChips(status: EventStatus, accessLevel: AccessLevel) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
EventStatus.Cancelled -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_cancelled),
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Tentative -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_tentative),
|
||||||
|
container = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Confirmed -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
when (accessLevel) {
|
||||||
|
AccessLevel.Private -> InfoChip(text = stringResource(R.string.event_access_private))
|
||||||
|
AccessLevel.Confidential ->
|
||||||
|
InfoChip(text = stringResource(R.string.event_access_confidential))
|
||||||
|
AccessLevel.Default, AccessLevel.Public -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoChip(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
content: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
) {
|
||||||
|
Surface(color = container, shape = RoundedCornerShape(8.dp), modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = content,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||||
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||||
@@ -361,6 +652,77 @@ private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
|||||||
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role badge shown under an attendee's name. Organizer wins over type;
|
||||||
|
* required attendees (the common case) get no badge to keep the list quiet.
|
||||||
|
*/
|
||||||
|
private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||||
|
attendee.relationship == AttendeeRelationship.Organizer -> R.string.event_attendee_organizer
|
||||||
|
attendee.type == AttendeeType.Optional -> R.string.event_attendee_optional
|
||||||
|
attendee.type == AttendeeType.Resource -> R.string.event_attendee_resource
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||||
|
@Composable
|
||||||
|
private fun reminderLeadText(reminder: Reminder): String {
|
||||||
|
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)"),
|
||||||
|
* but only when the event is timed and pinned to a zone different from the
|
||||||
|
* device's. Returns null when there's nothing worth showing.
|
||||||
|
*/
|
||||||
|
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||||
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
|
val deviceZone = ZoneId.systemDefault().id
|
||||||
|
if (tz == deviceZone) return null
|
||||||
|
return try {
|
||||||
|
val zone = ZoneId.of(tz)
|
||||||
|
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
|
if (name == tz) tz else "$name ($tz)"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
@Composable
|
||||||
|
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) {
|
||||||
|
val regex = Regex("""https?://\S+""")
|
||||||
|
val styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||||
|
)
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
for (match in regex.findAll(text)) {
|
||||||
|
// Trim trailing punctuation that commonly abuts a URL in prose.
|
||||||
|
val raw = match.value
|
||||||
|
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'')
|
||||||
|
val end = match.range.first + url.length
|
||||||
|
addLink(LinkAnnotation.Url(url, styles), match.range.first, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI state for the event-detail bottom sheet (spec S4). Read-only in V1.
|
* UI state for the event-detail screen (spec S4).
|
||||||
*/
|
*/
|
||||||
sealed interface EventDetailUiState {
|
sealed interface EventDetailUiState {
|
||||||
data object Loading : EventDetailUiState
|
data object Loading : EventDetailUiState
|
||||||
@@ -13,5 +13,20 @@ sealed interface EventDetailUiState {
|
|||||||
val detail: EventDetail,
|
val detail: EventDetail,
|
||||||
/** Display name of the owning calendar, null if it can't be resolved. */
|
/** Display name of the owning calendar, null if it can't be resolved. */
|
||||||
val calendarName: String?,
|
val calendarName: String?,
|
||||||
|
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||||
|
val canModify: Boolean = false,
|
||||||
) : EventDetailUiState
|
) : EventDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot state of a delete request, separate from the screen state so a
|
||||||
|
* failed delete leaves the loaded detail visible.
|
||||||
|
*/
|
||||||
|
sealed interface DeleteUiState {
|
||||||
|
data object Idle : DeleteUiState
|
||||||
|
data object Deleting : DeleteUiState
|
||||||
|
data object Deleted : DeleteUiState
|
||||||
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
data object NeedsPermission : DeleteUiState
|
||||||
|
data object Failed : DeleteUiState
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
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 kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -38,6 +40,9 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
// Bumped by retry() to re-run the load for the same target.
|
// Bumped by retry() to re-run the load for the same target.
|
||||||
private val _reload = MutableStateFlow(0)
|
private val _reload = MutableStateFlow(0)
|
||||||
|
|
||||||
|
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||||
|
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
val state: StateFlow<EventDetailUiState> =
|
val state: StateFlow<EventDetailUiState> =
|
||||||
combine(_target, _reload) { target, _ -> target }
|
combine(_target, _reload) { target, _ -> target }
|
||||||
.flatMapLatest { target ->
|
.flatMapLatest { target ->
|
||||||
@@ -72,6 +77,38 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
_reload.value += 1
|
_reload.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the open event. [wholeSeries] is meaningful only for recurring
|
||||||
|
* events: false cancels just the tapped occurrence. Result lands in
|
||||||
|
* [deleteState]; the screen consumes it via [consumeDeleteResult].
|
||||||
|
*/
|
||||||
|
fun delete(wholeSeries: Boolean) {
|
||||||
|
val target = _target.value ?: return
|
||||||
|
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_deleteState.value = DeleteUiState.Deleting
|
||||||
|
_deleteState.value = try {
|
||||||
|
if (wholeSeries) {
|
||||||
|
repository.deleteEvent(target.eventId)
|
||||||
|
} else {
|
||||||
|
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||||
|
}
|
||||||
|
DeleteUiState.Deleted
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
DeleteUiState.NeedsPermission
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DeleteUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [deleteState] after the screen handled a terminal result. */
|
||||||
|
fun consumeDeleteResult() {
|
||||||
|
_deleteState.value = DeleteUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -82,10 +119,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val calendarName = repository.calendars().first()
|
val calendar = repository.calendars().first()
|
||||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
?.displayName
|
EventDetailUiState.Success(
|
||||||
EventDetailUiState.Success(corrected, calendarName)
|
detail = corrected,
|
||||||
|
calendarName = calendar?.displayName,
|
||||||
|
canModify = calendar?.canModifyContents == true,
|
||||||
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: NoSuchEventException) {
|
} catch (e: NoSuchEventException) {
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen event form (v1.2: create only). Opens prefilled from the FAB's
|
||||||
|
* anchor date; Save validates, writes via the repository, and closes. The
|
||||||
|
* calendar picker offers only writable calendars.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EventEditScreen(
|
||||||
|
initialDateIso: String?,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(initialDateIso) {
|
||||||
|
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
|
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
viewModel.openNew(date)
|
||||||
|
}
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// The form is intentionally forgotten on every close (cancel or save) so
|
||||||
|
// the next FAB tap starts clean; it survives rotation because openNew
|
||||||
|
// no-ops while a form is set.
|
||||||
|
val close = {
|
||||||
|
viewModel.reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
BackHandler(onBack = close)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Contextual WRITE_CALENDAR upgrade for v1.0 installs, like delete.
|
||||||
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) viewModel.save()
|
||||||
|
}
|
||||||
|
val onSaveClick = {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
viewModel.save()
|
||||||
|
} else {
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||||
|
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||||
|
LaunchedEffect(state?.saveState) {
|
||||||
|
when (state?.saveState) {
|
||||||
|
SaveUiState.Saved -> close()
|
||||||
|
SaveUiState.Failed -> {
|
||||||
|
viewModel.consumeSaveResult()
|
||||||
|
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||||
|
}
|
||||||
|
SaveUiState.NeedsPermission -> {
|
||||||
|
viewModel.consumeSaveResult()
|
||||||
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
|
}
|
||||||
|
SaveUiState.Idle, SaveUiState.Saving, null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.event_edit_new_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = close) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onSaveClick,
|
||||||
|
enabled = state != null && state?.saveState != SaveUiState.Saving,
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.event_edit_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
state?.let { s ->
|
||||||
|
EventEditContent(
|
||||||
|
state = s,
|
||||||
|
viewModel = viewModel,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventEditContent(
|
||||||
|
state: EventEditUiState,
|
||||||
|
viewModel: EventEditViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val form = state.form
|
||||||
|
val locale = currentLocale()
|
||||||
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
|
) {
|
||||||
|
// Title: a large borderless field, MD3 "create" idiom.
|
||||||
|
TextField(
|
||||||
|
value = form.title,
|
||||||
|
onValueChange = viewModel::setTitle,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_title_hint),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.headlineSmall,
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// All-day toggle.
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_all_day),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Start / end rows: tappable date + (for timed events) time labels.
|
||||||
|
DateTimeRow(
|
||||||
|
label = stringResource(R.string.event_edit_starts),
|
||||||
|
dateTime = form.start,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
locale = locale,
|
||||||
|
onDateClick = { picker = PickerTarget.StartDate },
|
||||||
|
onTimeClick = { picker = PickerTarget.StartTime },
|
||||||
|
)
|
||||||
|
DateTimeRow(
|
||||||
|
label = stringResource(R.string.event_edit_ends),
|
||||||
|
dateTime = form.end,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
locale = locale,
|
||||||
|
onDateClick = { picker = PickerTarget.EndDate },
|
||||||
|
onTimeClick = { picker = PickerTarget.EndTime },
|
||||||
|
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||||
|
)
|
||||||
|
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_error_end_before_start),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(start = 40.dp, top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Calendar picker row.
|
||||||
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = state.calendars.isNotEmpty()) { showCalendarPicker = true }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CalendarMonth,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
|
tint = selectedCalendar?.let { pastelize(it.color, dark) }
|
||||||
|
?: MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = selectedCalendar?.displayName
|
||||||
|
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (selectedCalendar == null) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
Color.Unspecified
|
||||||
|
},
|
||||||
|
)
|
||||||
|
selectedCalendar?.accountName?.takeIf { it.isNotBlank() }?.let { account ->
|
||||||
|
Text(
|
||||||
|
text = account,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = form.location,
|
||||||
|
onValueChange = viewModel::setLocation,
|
||||||
|
label = { Text(stringResource(R.string.event_detail_location)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Place, contentDescription = null) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = form.description,
|
||||||
|
onValueChange = viewModel::setDescription,
|
||||||
|
label = { Text(stringResource(R.string.event_detail_description)) },
|
||||||
|
leadingIcon = { Icon(Icons.AutoMirrored.Filled.Notes, contentDescription = null) },
|
||||||
|
minLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (picker) {
|
||||||
|
PickerTarget.StartDate -> DatePickerAlert(
|
||||||
|
initial = form.start.date,
|
||||||
|
onConfirm = { viewModel.setStartDate(it); picker = null },
|
||||||
|
onDismiss = { picker = null },
|
||||||
|
)
|
||||||
|
PickerTarget.EndDate -> DatePickerAlert(
|
||||||
|
initial = form.end.date,
|
||||||
|
onConfirm = { viewModel.setEndDate(it); picker = null },
|
||||||
|
onDismiss = { picker = null },
|
||||||
|
)
|
||||||
|
PickerTarget.StartTime -> TimePickerAlert(
|
||||||
|
initial = form.start.time,
|
||||||
|
onConfirm = { viewModel.setStartTime(it); picker = null },
|
||||||
|
onDismiss = { picker = null },
|
||||||
|
)
|
||||||
|
PickerTarget.EndTime -> TimePickerAlert(
|
||||||
|
initial = form.end.time,
|
||||||
|
onConfirm = { viewModel.setEndTime(it); picker = null },
|
||||||
|
onDismiss = { picker = null },
|
||||||
|
)
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCalendarPicker) {
|
||||||
|
CalendarPickerDialog(
|
||||||
|
calendars = state.calendars,
|
||||||
|
selectedId = form.calendarId,
|
||||||
|
onSelect = {
|
||||||
|
viewModel.setCalendar(it)
|
||||||
|
showCalendarPicker = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCalendarPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One schedule row: label, then tappable date and (unless all-day) time. */
|
||||||
|
@Composable
|
||||||
|
private fun DateTimeRow(
|
||||||
|
label: String,
|
||||||
|
dateTime: LocalDateTime,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
onDateClick: () -> Unit,
|
||||||
|
onTimeClick: () -> Unit,
|
||||||
|
isError: Boolean = false,
|
||||||
|
) {
|
||||||
|
val dateFormat = remember(locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
}
|
||||||
|
val timeFormat = remember(locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
}
|
||||||
|
val contentColor = if (isError) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 40.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = dateFormat.format(dateTime.date.toJavaLocalDate()),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = contentColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onDateClick)
|
||||||
|
.padding(8.dp),
|
||||||
|
)
|
||||||
|
if (!isAllDay) {
|
||||||
|
Text(
|
||||||
|
text = timeFormat.format(dateTime.time.toJavaLocalTime()),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = contentColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onTimeClick)
|
||||||
|
.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
private fun CalendarPickerDialog(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
selectedId: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.event_detail_calendar)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
calendars.forEach { calendar ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = calendar.id == selectedId,
|
||||||
|
role = Role.RadioButton,
|
||||||
|
onClick = { onSelect(calendar.id) },
|
||||||
|
)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(selected = calendar.id == selectedId, onClick = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = pastelize(calendar.color, dark),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Text(calendar.displayName, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (calendar.accountName.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = calendar.accountName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MILLIS_PER_DAY = 86_400_000L
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
|
||||||
|
* form means the screen hasn't been opened yet.
|
||||||
|
*/
|
||||||
|
data class EventEditUiState(
|
||||||
|
/** The form with its calendar id resolved (picked > last used > first writable). */
|
||||||
|
val form: EventForm,
|
||||||
|
/** Calendars that accept writes — the only valid targets. */
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
/** Validation problems; empty until a save was attempted. */
|
||||||
|
val problems: Set<EventFormProblem>,
|
||||||
|
val saveState: SaveUiState,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||||
|
sealed interface SaveUiState {
|
||||||
|
data object Idle : SaveUiState
|
||||||
|
data object Saving : SaveUiState
|
||||||
|
data object Saved : SaveUiState
|
||||||
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
data object NeedsPermission : SaveUiState
|
||||||
|
data object Failed : SaveUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
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.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
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.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
|
* shows and what gets saved.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventEditViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _form = MutableStateFlow<EventForm?>(null)
|
||||||
|
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
|
||||||
|
// Problems stay hidden until the first save attempt, so a half-filled
|
||||||
|
// form isn't already shouting errors.
|
||||||
|
private val _showProblems = MutableStateFlow(false)
|
||||||
|
|
||||||
|
val state: StateFlow<EventEditUiState?> = combine(
|
||||||
|
_form,
|
||||||
|
repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) },
|
||||||
|
prefs.lastUsedCalendarId,
|
||||||
|
_saveState,
|
||||||
|
_showProblems,
|
||||||
|
) { form, writable, lastUsed, saveState, showProblems ->
|
||||||
|
if (form == null) return@combine null
|
||||||
|
val resolvedId = form.calendarId
|
||||||
|
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
|
||||||
|
?: writable.firstOrNull()?.id
|
||||||
|
val resolved = form.copy(calendarId = resolvedId)
|
||||||
|
EventEditUiState(
|
||||||
|
form = resolved,
|
||||||
|
calendars = writable,
|
||||||
|
problems = if (showProblems) resolved.problems() else emptySet(),
|
||||||
|
saveState = saveState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise a fresh form for a new event on [date]. No-op when a form is
|
||||||
|
* already open, so user input survives configuration changes; [reset]
|
||||||
|
* clears it when the screen closes.
|
||||||
|
*/
|
||||||
|
fun openNew(date: LocalDate) {
|
||||||
|
if (_form.value != null) return
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val start = if (date == now.toLocalDateTime(zone).date) {
|
||||||
|
// Today: the next full hour (may roll into tomorrow before midnight).
|
||||||
|
val hourMillis = 3_600_000L
|
||||||
|
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||||
|
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||||
|
} else {
|
||||||
|
LocalDateTime(date, LocalTime(9, 0))
|
||||||
|
}
|
||||||
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the open form; the next [openNew] starts clean. */
|
||||||
|
fun reset() {
|
||||||
|
_form.value = null
|
||||||
|
_saveState.value = SaveUiState.Idle
|
||||||
|
_showProblems.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||||
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
|
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
||||||
|
fun setCalendar(id: Long) = update { it.copy(calendarId = id) }
|
||||||
|
|
||||||
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
|
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||||
|
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||||
|
|
||||||
|
/** Validate and write. Terminal results land in [saveState]. */
|
||||||
|
fun save() {
|
||||||
|
val current = state.value ?: return
|
||||||
|
if (current.saveState == SaveUiState.Saving) return
|
||||||
|
val form = current.form
|
||||||
|
if (form.problems().isNotEmpty()) {
|
||||||
|
_showProblems.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_saveState.value = SaveUiState.Saving
|
||||||
|
_saveState.value = try {
|
||||||
|
repository.createEvent(form)
|
||||||
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
|
SaveUiState.Saved
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
SaveUiState.NeedsPermission
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SaveUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [saveState] after the screen handled a terminal result. */
|
||||||
|
fun consumeSaveResult() {
|
||||||
|
_saveState.value = SaveUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val newStart = transform(form.start)
|
||||||
|
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
|
||||||
|
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone)
|
||||||
|
form.copy(start = newStart, end = newEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun update(block: (EventForm) -> EventForm) {
|
||||||
|
_form.value = _form.value?.let(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -23,13 +20,11 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
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
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -62,6 +57,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -74,8 +70,11 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -86,6 +85,7 @@ fun MonthScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -147,17 +147,20 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnCurrentMonth,
|
todayVisible = !isOnCurrentMonth,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.month_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = {
|
||||||
ExtendedFloatingActionButton(
|
// Anchor on today when its month is shown, else the 1st.
|
||||||
onClick = jumpToToday,
|
val today = Clock.System.now()
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
text = { Text(stringResource(R.string.month_today_action)) },
|
onCreateEvent(
|
||||||
|
if (isOnCurrentMonth) today
|
||||||
|
else LocalDate(month.year, month.month, 1),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -6,28 +6,70 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.Scaffold
|
||||||
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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
private val CALENDAR_PERMISSIONS = arrayOf(
|
||||||
|
Manifest.permission.READ_CALENDAR,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
)
|
||||||
|
|
||||||
|
// MD3 8dp spacing scale, scoped to this screen.
|
||||||
|
private object Space {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
@@ -36,10 +78,17 @@ fun PermissionScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// READ and WRITE are requested together (one system dialog — same
|
||||||
|
// permission group), but only READ gates the app: declining write keeps
|
||||||
|
// Calendula usable read-only.
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
) { granted ->
|
) { results ->
|
||||||
if (granted) viewModel.onGranted() else viewModel.onDenied()
|
if (results[Manifest.permission.READ_CALENDAR] == true) {
|
||||||
|
viewModel.onGranted()
|
||||||
|
} else {
|
||||||
|
viewModel.onDenied()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
@@ -48,13 +97,13 @@ fun PermissionScreen(
|
|||||||
|
|
||||||
when (state) {
|
when (state) {
|
||||||
is PermissionUiState.Rationale -> RationaleContent(
|
is PermissionUiState.Rationale -> RationaleContent(
|
||||||
onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) },
|
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Denied -> DeniedContent(
|
is PermissionUiState.Denied -> DeniedContent(
|
||||||
onRetry = {
|
onRetry = {
|
||||||
viewModel.onRetry()
|
viewModel.onRetry()
|
||||||
launcher.launch(Manifest.permission.READ_CALENDAR)
|
launcher.launch(CALENDAR_PERMISSIONS)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -69,24 +118,68 @@ private fun RationaleContent(
|
|||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
PermissionScaffold(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.Center,
|
hero = { BrandHero(denied = false) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onRequest,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_request_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(Space.xs))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PrivacyFootnote()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_body),
|
text = stringResource(R.string.permission_rationale_body),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(Space.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.CalendarMonth,
|
||||||
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_sync_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.VisibilityOff,
|
||||||
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_privacy_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Button(onClick = onRequest) {
|
|
||||||
Text(stringResource(R.string.permission_request_button))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,26 +189,11 @@ private fun DeniedContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Column(
|
PermissionScaffold(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.Center,
|
hero = { BrandHero(denied = true) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
actions = {
|
||||||
) {
|
Button(
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.permission_denied_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.permission_denied_body),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Button(onClick = onRetry) {
|
|
||||||
Text(stringResource(R.string.permission_retry_button))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
data = Uri.fromParts("package", context.packageName, null)
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
@@ -123,8 +201,170 @@ private fun DeniedContent(
|
|||||||
}
|
}
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.permission_open_settings_button))
|
Text(
|
||||||
|
text = stringResource(R.string.permission_open_settings_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.permission_retry_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_denied_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_denied_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
|
||||||
|
* action pinned to the bottom (clear of the navigation bar). The content slot is
|
||||||
|
* centred horizontally; benefit rows fill the width so their own content
|
||||||
|
* left-aligns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PermissionScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = Space.md, vertical = Space.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = Space.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(Space.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(Space.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(Space.md))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
private fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(Space.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrivacyFootnote() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_privacy_footnote),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
@@ -31,12 +28,10 @@ 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.Refresh
|
|
||||||
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
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -77,6 +72,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -88,7 +84,10 @@ import de.jeanlucmakiola.calendula.ui.common.pastelize
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -113,6 +112,7 @@ fun WeekScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -174,17 +174,17 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnCurrentWeek,
|
todayVisible = !isOnCurrentWeek,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.week_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = {
|
||||||
ExtendedFloatingActionButton(
|
// Anchor on today when it's in view, else the week's first day.
|
||||||
onClick = jumpToToday,
|
val today = Clock.System.now()
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
text = { Text(stringResource(R.string.week_today_action)) },
|
onCreateEvent(if (isOnCurrentWeek) today else weekStart)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
WeekContent(
|
WeekContent(
|
||||||
|
|||||||
@@ -12,13 +12,20 @@
|
|||||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||||
|
|
||||||
<!-- Permission-Flow (F1) -->
|
<!-- Permission-Flow (F1) -->
|
||||||
<string name="permission_rationale_title">Kalender-Zugriff</string>
|
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||||
<string name="permission_rationale_body">Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.</string>
|
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||||
<string name="permission_request_button">Weiter</string>
|
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||||
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
||||||
<string name="permission_retry_button">Erneut versuchen</string>
|
<string name="permission_retry_button">Erneut versuchen</string>
|
||||||
|
<string name="permission_benefit_private_title">Bleibt auf deinem Gerät</string>
|
||||||
|
<string name="permission_benefit_private_body">Deine Kalender werden lokal gelesen und verlassen das Telefon nie.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
||||||
|
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
||||||
|
|
||||||
<!-- Monatsansicht (S1) -->
|
<!-- Monatsansicht (S1) -->
|
||||||
<string name="month_prev">Vorheriger Monat</string>
|
<string name="month_prev">Vorheriger Monat</string>
|
||||||
@@ -38,8 +45,28 @@
|
|||||||
|
|
||||||
<!-- Event-Detail-Screen (S4) -->
|
<!-- Event-Detail-Screen (S4) -->
|
||||||
<string name="event_detail_back">Zurück</string>
|
<string name="event_detail_back">Zurück</string>
|
||||||
<string name="event_detail_edit">Bearbeiten</string>
|
|
||||||
<string name="event_detail_delete">Löschen</string>
|
<string name="event_detail_delete">Löschen</string>
|
||||||
|
<string name="event_delete_title">Termin löschen?</string>
|
||||||
|
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
||||||
|
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
||||||
|
<string name="event_delete_option_occurrence">Nur dieser Termin</string>
|
||||||
|
<string name="event_delete_option_series">Alle Termine der Serie</string>
|
||||||
|
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||||
|
<string name="dialog_cancel">Abbrechen</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular (v1.2 Erstellen) -->
|
||||||
|
<string name="event_edit_new_title">Neuer Termin</string>
|
||||||
|
<string name="event_edit_close">Schließen</string>
|
||||||
|
<string name="event_edit_save">Speichern</string>
|
||||||
|
<string name="event_edit_title_hint">Titel hinzufügen</string>
|
||||||
|
<string name="event_edit_starts">Beginn</string>
|
||||||
|
<string name="event_edit_ends">Ende</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Endet vor dem Beginn</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Kein beschreibbarer Kalender verfügbar</string>
|
||||||
|
<string name="event_edit_save_failed">Termin konnte nicht gespeichert werden</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula braucht Schreibzugriff, um Termine zu erstellen</string>
|
||||||
<string name="event_detail_all_day">Ganztägig</string>
|
<string name="event_detail_all_day">Ganztägig</string>
|
||||||
<string name="event_detail_calendar">Kalender</string>
|
<string name="event_detail_calendar">Kalender</string>
|
||||||
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
||||||
@@ -66,6 +93,37 @@
|
|||||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
|
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||||
|
<string name="event_detail_reminders">Erinnerungen</string>
|
||||||
|
<string name="event_detail_timezone">Zeitzone</string>
|
||||||
|
<string name="event_status_tentative">Vorläufig</string>
|
||||||
|
<string name="event_status_cancelled">Abgesagt</string>
|
||||||
|
<string name="event_availability_free">Frei</string>
|
||||||
|
<string name="event_access_private">Privat</string>
|
||||||
|
<string name="event_access_confidential">Vertraulich</string>
|
||||||
|
<string name="event_attendee_organizer">Organisator</string>
|
||||||
|
<string name="event_attendee_optional">Optional</string>
|
||||||
|
<string name="event_attendee_resource">Ressource</string>
|
||||||
|
<string name="event_detail_self_response">Deine Antwort: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Zur Startzeit</string>
|
||||||
|
<string name="reminder_default">Standarderinnerung</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d Minute vorher</item>
|
||||||
|
<item quantity="other">%d Minuten vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d Stunde vorher</item>
|
||||||
|
<item quantity="other">%d Stunden vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d Tag vorher</item>
|
||||||
|
<item quantity="other">%d Tage vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d Woche vorher</item>
|
||||||
|
<item quantity="other">%d Wochen vorher</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,20 @@
|
|||||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||||
|
|
||||||
<!-- Permission flow (F1) -->
|
<!-- Permission flow (F1) -->
|
||||||
<string name="permission_rationale_title">Calendar access</string>
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
<string name="permission_rationale_body">Calendula reads only your device calendar — no data leaves your device.</string>
|
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
||||||
<string name="permission_request_button">Continue</string>
|
<string name="permission_request_button">Grant calendar access</string>
|
||||||
<string name="permission_denied_title">Calendar access denied</string>
|
<string name="permission_denied_title">Calendar access denied</string>
|
||||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||||
<string name="permission_open_settings_button">Open system settings</string>
|
<string name="permission_open_settings_button">Open system settings</string>
|
||||||
<string name="permission_retry_button">Try again</string>
|
<string name="permission_retry_button">Try again</string>
|
||||||
|
<string name="permission_benefit_private_title">Stays on your device</string>
|
||||||
|
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
|
||||||
|
<string name="permission_benefit_sync_title">All your calendars, together</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">No tracking, ever</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
|
||||||
|
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
|
||||||
|
|
||||||
<!-- Month view (S1) -->
|
<!-- Month view (S1) -->
|
||||||
<string name="month_prev">Previous month</string>
|
<string name="month_prev">Previous month</string>
|
||||||
@@ -39,8 +46,28 @@
|
|||||||
|
|
||||||
<!-- Event detail screen (S4) -->
|
<!-- Event detail screen (S4) -->
|
||||||
<string name="event_detail_back">Back</string>
|
<string name="event_detail_back">Back</string>
|
||||||
<string name="event_detail_edit">Edit</string>
|
|
||||||
<string name="event_detail_delete">Delete</string>
|
<string name="event_detail_delete">Delete</string>
|
||||||
|
<string name="event_delete_title">Delete event?</string>
|
||||||
|
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
|
||||||
|
<string name="event_delete_recurring_title">Delete recurring event</string>
|
||||||
|
<string name="event_delete_option_occurrence">Only this event</string>
|
||||||
|
<string name="event_delete_option_series">All events in the series</string>
|
||||||
|
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||||
|
<string name="dialog_cancel">Cancel</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
|
<!-- Event form (v1.2 create) -->
|
||||||
|
<string name="event_edit_new_title">New event</string>
|
||||||
|
<string name="event_edit_close">Close</string>
|
||||||
|
<string name="event_edit_save">Save</string>
|
||||||
|
<string name="event_edit_title_hint">Add title</string>
|
||||||
|
<string name="event_edit_starts">Starts</string>
|
||||||
|
<string name="event_edit_ends">Ends</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||||
|
<string name="event_edit_error_no_calendar">No writable calendar available</string>
|
||||||
|
<string name="event_edit_save_failed">Couldn\'t save the event</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula needs write access to create events</string>
|
||||||
<string name="event_detail_all_day">All day</string>
|
<string name="event_detail_all_day">All day</string>
|
||||||
<string name="event_detail_calendar">Calendar</string>
|
<string name="event_detail_calendar">Calendar</string>
|
||||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||||
@@ -67,6 +94,37 @@
|
|||||||
<string name="event_attendee_needs_action">No response</string>
|
<string name="event_attendee_needs_action">No response</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
|
<!-- Event detail — full read (v0.6) -->
|
||||||
|
<string name="event_detail_reminders">Reminders</string>
|
||||||
|
<string name="event_detail_timezone">Time zone</string>
|
||||||
|
<string name="event_status_tentative">Tentative</string>
|
||||||
|
<string name="event_status_cancelled">Cancelled</string>
|
||||||
|
<string name="event_availability_free">Free</string>
|
||||||
|
<string name="event_access_private">Private</string>
|
||||||
|
<string name="event_access_confidential">Confidential</string>
|
||||||
|
<string name="event_attendee_organizer">Organizer</string>
|
||||||
|
<string name="event_attendee_optional">Optional</string>
|
||||||
|
<string name="event_attendee_resource">Resource</string>
|
||||||
|
<string name="event_detail_self_response">Your response: %1$s</string>
|
||||||
|
<string name="reminder_at_time">At time of event</string>
|
||||||
|
<string name="reminder_default">Default reminder</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minute before</item>
|
||||||
|
<item quantity="other">%d minutes before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hour before</item>
|
||||||
|
<item quantity="other">%d hours before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d day before</item>
|
||||||
|
<item quantity="other">%d days before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d week before</item>
|
||||||
|
<item quantity="other">%d weeks before</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ class CalendarMapperTest {
|
|||||||
accountType: String? = "LOCAL",
|
accountType: String? = "LOCAL",
|
||||||
color: Int = 0,
|
color: Int = 0,
|
||||||
visible: Int = 1,
|
visible: Int = 1,
|
||||||
|
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
CalendarProjection.IDX_ID to id,
|
CalendarProjection.IDX_ID to id,
|
||||||
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
||||||
@@ -19,6 +21,7 @@ class CalendarMapperTest {
|
|||||||
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
|
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
|
||||||
CalendarProjection.IDX_COLOR to color,
|
CalendarProjection.IDX_COLOR to color,
|
||||||
CalendarProjection.IDX_VISIBLE to visible,
|
CalendarProjection.IDX_VISIBLE to visible,
|
||||||
|
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -39,6 +42,7 @@ class CalendarMapperTest {
|
|||||||
accountType = "com.google",
|
accountType = "com.google",
|
||||||
color = 0xFF112233.toInt(),
|
color = 0xFF112233.toInt(),
|
||||||
isVisibleInSystem = true,
|
isVisibleInSystem = true,
|
||||||
|
canModifyContents = true,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -65,4 +69,25 @@ class CalendarMapperTest {
|
|||||||
assertThat(src.accountName).isEqualTo("")
|
assertThat(src.accountName).isEqualTo("")
|
||||||
assertThat(src.accountType).isEqualTo("")
|
assertThat(src.accountType).isEqualTo("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contributor access and above can modify contents`() {
|
||||||
|
val contributor = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR)
|
||||||
|
val owner = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||||
|
assertThat(contributor.toCalendarSource().canModifyContents).isTrue()
|
||||||
|
assertThat(owner.toCalendarSource().canModifyContents).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `read access cannot modify contents`() {
|
||||||
|
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_READ)
|
||||||
|
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing access level defaults to read-only`() {
|
||||||
|
// WebCal subscriptions on some providers report level 0 (CAL_ACCESS_NONE).
|
||||||
|
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
|
||||||
|
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import app.cash.turbine.test
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -157,6 +161,80 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Stand-up",
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val id = repo.createEvent(form)
|
||||||
|
|
||||||
|
assertThat(id).isEqualTo(77L)
|
||||||
|
assertThat(fake.insertedForms).containsExactly(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
writeError = WriteFailedException("insert event")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.createEvent(form)
|
||||||
|
error("Expected WriteFailedException")
|
||||||
|
} catch (expected: WriteFailedException) {
|
||||||
|
assertThat(expected.message).contains("insert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.deleteEvent(eventId = 42L)
|
||||||
|
|
||||||
|
assertThat(fake.deletedEventIds).containsExactly(42L)
|
||||||
|
assertThat(fake.deletedOccurrences).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||||
|
|
||||||
|
assertThat(fake.deletedOccurrences).containsExactly(42L to 1_000L)
|
||||||
|
assertThat(fake.deletedEventIds).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
writeError = WriteFailedException("delete event id=42")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.deleteEvent(eventId = 42L)
|
||||||
|
error("Expected WriteFailedException")
|
||||||
|
} catch (expected: WriteFailedException) {
|
||||||
|
assertThat(expected.message).contains("42")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class EventDetailMapperTest {
|
class EventDetailMapperTest {
|
||||||
@@ -19,6 +26,11 @@ class EventDetailMapperTest {
|
|||||||
allDay: Int = 0,
|
allDay: Int = 0,
|
||||||
location: String? = "Berlin",
|
location: String? = "Berlin",
|
||||||
calendarId: Long = 7L,
|
calendarId: Long = 7L,
|
||||||
|
status: Any? = null,
|
||||||
|
availability: Any? = null,
|
||||||
|
accessLevel: Any? = null,
|
||||||
|
timezone: String? = null,
|
||||||
|
selfStatus: Any? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -32,18 +44,42 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_ALL_DAY to allDay,
|
EventDetailProjection.IDX_ALL_DAY to allDay,
|
||||||
EventDetailProjection.IDX_LOCATION to location,
|
EventDetailProjection.IDX_LOCATION to location,
|
||||||
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
|
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
|
||||||
|
EventDetailProjection.IDX_STATUS to status,
|
||||||
|
EventDetailProjection.IDX_AVAILABILITY to availability,
|
||||||
|
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(name: String?, email: String?, status: Int): MapColumnReader =
|
private fun attendeeReader(
|
||||||
|
name: String?,
|
||||||
|
email: String?,
|
||||||
|
status: Int,
|
||||||
|
relationship: Int = 0,
|
||||||
|
type: Int = 0,
|
||||||
|
): MapColumnReader =
|
||||||
MapColumnReader(
|
MapColumnReader(
|
||||||
AttendeeProjection.IDX_NAME to name,
|
AttendeeProjection.IDX_NAME to name,
|
||||||
AttendeeProjection.IDX_EMAIL to email,
|
AttendeeProjection.IDX_EMAIL to email,
|
||||||
AttendeeProjection.IDX_STATUS to status,
|
AttendeeProjection.IDX_STATUS to status,
|
||||||
|
AttendeeProjection.IDX_RELATIONSHIP to relationship,
|
||||||
|
AttendeeProjection.IDX_TYPE to type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun reminderReader(minutes: Int, method: Int): MapColumnReader =
|
||||||
|
MapColumnReader(
|
||||||
|
ReminderProjection.IDX_MINUTES to minutes,
|
||||||
|
ReminderProjection.IDX_METHOD to method,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MapColumnReader.toDetail(
|
||||||
|
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
|
||||||
|
reminders: List<Reminder> = emptyList(),
|
||||||
|
) = toEventDetailCore(attendees, reminders)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
||||||
val detail = detailReader().toEventDetailCore(attendees = emptyList())
|
val detail = detailReader().toDetail()
|
||||||
assertThat(detail).isNotNull()
|
assertThat(detail).isNotNull()
|
||||||
assertThat(detail!!.description).isEqualTo("Body")
|
assertThat(detail!!.description).isEqualTo("Body")
|
||||||
assertThat(detail.organizer).isEqualTo("x@y")
|
assertThat(detail.organizer).isEqualTo("x@y")
|
||||||
@@ -55,21 +91,19 @@ class EventDetailMapperTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `event color falls back to calendar color when null`() {
|
fun `event color falls back to calendar color when null`() {
|
||||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
.toEventDetailCore(attendees = emptyList())
|
.toDetail()
|
||||||
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dtend before dtstart drops detail`() {
|
fun `dtend before dtstart drops detail`() {
|
||||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L)
|
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||||
.toEventDetailCore(attendees = emptyList())
|
|
||||||
assertThat(detail).isNull()
|
assertThat(detail).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rrule passes through when present`() {
|
fun `rrule passes through when present`() {
|
||||||
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
|
||||||
.toEventDetailCore(attendees = emptyList())
|
|
||||||
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,4 +138,82 @@ class EventDetailMapperTest {
|
|||||||
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
|
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
|
||||||
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
|
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RELATIONSHIP: NONE=0, ATTENDEE=1, ORGANIZER=2, PERFORMER=3, SPEAKER=4
|
||||||
|
@Test
|
||||||
|
fun `attendee relationship maps known integer codes`() {
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 2).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.Organizer)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 1).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.Attendee)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 0).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TYPE: NONE=0, REQUIRED=1, OPTIONAL=2, RESOURCE=3
|
||||||
|
@Test
|
||||||
|
fun `attendee type maps known integer codes`() {
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 1).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Required)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 2).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Optional)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 3).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS: TENTATIVE=0, CONFIRMED=1, CANCELED=2
|
||||||
|
@Test
|
||||||
|
fun `event status null maps to confirmed, codes map through`() {
|
||||||
|
assertThat(detailReader(status = null).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
|
||||||
|
assertThat(detailReader(status = 0).toDetail()!!.status).isEqualTo(EventStatus.Tentative)
|
||||||
|
assertThat(detailReader(status = 1).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
|
||||||
|
assertThat(detailReader(status = 2).toDetail()!!.status).isEqualTo(EventStatus.Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AVAILABILITY: BUSY=0, FREE=1, TENTATIVE=2
|
||||||
|
@Test
|
||||||
|
fun `availability null or busy maps to Busy, free maps to Free`() {
|
||||||
|
assertThat(detailReader(availability = null).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Busy)
|
||||||
|
assertThat(detailReader(availability = 0).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Busy)
|
||||||
|
assertThat(detailReader(availability = 1).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Free)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACCESS_LEVEL: DEFAULT=0, CONFIDENTIAL=1, PRIVATE=2, PUBLIC=3
|
||||||
|
@Test
|
||||||
|
fun `access level maps known integer codes, null is Default`() {
|
||||||
|
assertThat(detailReader(accessLevel = null).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Default)
|
||||||
|
assertThat(detailReader(accessLevel = 1).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Confidential)
|
||||||
|
assertThat(detailReader(accessLevel = 2).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Private)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event timezone and self status pass through`() {
|
||||||
|
val detail = detailReader(timezone = "Europe/Berlin", selfStatus = 1).toDetail()
|
||||||
|
assertThat(detail!!.eventTimezone).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(detail.selfStatus).isEqualTo(AttendeeStatus.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders pass through to the detail`() {
|
||||||
|
val reminders = listOf(Reminder(10, ReminderMethod.Alert))
|
||||||
|
val detail = detailReader().toDetail(reminders = reminders)
|
||||||
|
assertThat(detail!!.reminders).isEqualTo(reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// METHOD: DEFAULT=0, ALERT=1, EMAIL=2, SMS=3, ALARM=4
|
||||||
|
@Test
|
||||||
|
fun `reminder maps minutes and method codes`() {
|
||||||
|
assertThat(reminderReader(10, 1).toReminder())
|
||||||
|
.isEqualTo(Reminder(10, ReminderMethod.Alert))
|
||||||
|
assertThat(reminderReader(60, 2).toReminder())
|
||||||
|
.isEqualTo(Reminder(60, ReminderMethod.Email))
|
||||||
|
assertThat(reminderReader(0, 0).toReminder())
|
||||||
|
.isEqualTo(Reminder(0, ReminderMethod.Default))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class EventWriteMapperTest {
|
||||||
|
|
||||||
|
private val berlin: ZoneId = ZoneId.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun form(
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
|
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
||||||
|
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event resolves wall clock in the given zone`() {
|
||||||
|
val times = form().toWriteTimes(berlin)
|
||||||
|
// 2026-06-11 is CEST (UTC+2): 10:00 local == 08:00Z.
|
||||||
|
assertThat(times.dtStartMillis).isEqualTo(1_781_164_800_000L)
|
||||||
|
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(90L * 60L * 1000L)
|
||||||
|
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||||
|
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||||
|
assertThat(times.timezone).isEqualTo("UTC")
|
||||||
|
assertThat(times.dtStartMillis % 86_400_000L).isEqualTo(0L)
|
||||||
|
// Single-day all-day event: DTEND is the NEXT UTC midnight.
|
||||||
|
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(86_400_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-day all-day event spans every covered day`() {
|
||||||
|
val times = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(0, 0)),
|
||||||
|
).toWriteTimes(berlin)
|
||||||
|
// 11th, 12th, 13th inclusive = 3 days.
|
||||||
|
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,14 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var calendarsResult: List<CalendarSource> = emptyList()
|
var calendarsResult: List<CalendarSource> = emptyList()
|
||||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
|
/** Set to make the next write call throw. */
|
||||||
|
var writeError: Exception? = null
|
||||||
|
/** Id returned by the next [insertEvent]. */
|
||||||
|
var nextInsertId: Long = 100L
|
||||||
|
|
||||||
|
val insertedForms = mutableListOf<EventForm>()
|
||||||
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
|
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||||
|
|
||||||
private val listeners = mutableListOf<() -> Unit>()
|
private val listeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
@@ -20,6 +29,22 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
insertedForms += form
|
||||||
|
return nextInsertId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteEvent(eventId: Long) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
deletedEventIds += eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
deletedOccurrences += eventId to beginMillis
|
||||||
|
}
|
||||||
override fun registerChangeListener(listener: () -> Unit) {
|
override fun registerChangeListener(listener: () -> Unit) {
|
||||||
listeners += listener
|
listeners += listener
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventFormTest {
|
||||||
|
|
||||||
|
private fun form(
|
||||||
|
calendarId: Long? = 1L,
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
|
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||||
|
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid timed form has no problems`() {
|
||||||
|
assertThat(form().problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing calendar is a problem`() {
|
||||||
|
assertThat(form(calendarId = null).problems())
|
||||||
|
.containsExactly(EventFormProblem.NoCalendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed end before start is a problem`() {
|
||||||
|
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
|
||||||
|
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zero-length timed event is allowed`() {
|
||||||
|
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||||
|
assertThat(instant.problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day single day is allowed even though times match`() {
|
||||||
|
val allDay = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
assertThat(allDay.problems()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day end date before start date is a problem`() {
|
||||||
|
val bad = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `problems accumulate`() {
|
||||||
|
val bad = form(
|
||||||
|
calendarId = null,
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
||||||
|
)
|
||||||
|
assertThat(bad.problems()).containsExactly(
|
||||||
|
EventFormProblem.NoCalendar,
|
||||||
|
EventFormProblem.EndBeforeStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
109
docs/superpowers/plans/2026-06-11-03-write-support.md
Normal file
109
docs/superpowers/plans/2026-06-11-03-write-support.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Calendula - Plan 03: Write Support (Milestone 2 / v2.0)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Calendula kann Events anlegen, bearbeiten und löschen — direkt über
|
||||||
|
`CalendarContract`-Writes, ohne eigene DB. Der V1-Spec dient als Leitplanke,
|
||||||
|
nicht als Gesetz: Ausgeliefert wird in vier Slices (v1.1 → v2.0), jeder Slice
|
||||||
|
ist für sich releasebar und lässt `./gradlew lint test assembleDebug` grün.
|
||||||
|
|
||||||
|
**Architecture:** Writes laufen durch dieselbe Schichtung wie Reads:
|
||||||
|
`ui/` → `CalendarRepository` (Interface) → `CalendarDataSource` →
|
||||||
|
`ContentResolver.insert/update/delete`. Kein neuer Layer, keine Transaktions-
|
||||||
|
Abstraktion — der Provider notified nach jedem Write selbst, der bestehende
|
||||||
|
`ContentObserver`-Tick aktualisiert alle Views automatisch (F3 gilt unverändert).
|
||||||
|
Domain bleibt pure Kotlin.
|
||||||
|
|
||||||
|
**Leitentscheidungen (Abweichungen / Präzisierungen ggü. Spec §2 "V2"):**
|
||||||
|
|
||||||
|
1. **Permission-Strategie:** `WRITE_CALENDAR` kommt ins Manifest. Das Onboarding
|
||||||
|
fragt READ+WRITE zusammen an (eine System-Dialog-Gruppe), zwingend bleibt
|
||||||
|
nur READ — wer Write ablehnt, nutzt die App weiter read-only.
|
||||||
|
v1.0-Upgrader (haben nur READ) bekommen den WRITE-Request kontextuell beim
|
||||||
|
ersten Schreib-Versuch. Onboarding-Footnote verliert die "Nur Lesezugriff"-
|
||||||
|
Behauptung (wäre mit Manifest-Eintrag gelogen).
|
||||||
|
2. **Read-only-Kalender respektieren:** `Calendars.CALENDAR_ACCESS_LEVEL` wird
|
||||||
|
mitgelesen (`canModifyContents` = Level ≥ `CAL_ACCESS_CONTRIBUTOR`).
|
||||||
|
Edit/Delete-Actions erscheinen gar nicht erst für WebCal-Subscriptions,
|
||||||
|
Geburtstags- und andere read-only-Kalender.
|
||||||
|
3. **Recurring Events:** Löschen bietet "Nur dieser Termin" (Exception-Insert
|
||||||
|
via `Events.CONTENT_EXCEPTION_URI` mit `STATUS_CANCELED` +
|
||||||
|
`ORIGINAL_INSTANCE_TIME`) vs. "Ganze Serie" (Delete der Events-Row).
|
||||||
|
Bearbeiten startet mit "ganze Serie"; Occurrence-Edit (Exception mit neuen
|
||||||
|
Werten) folgt erst, wenn das Serien-Edit stabil ist.
|
||||||
|
4. **Kein RRULE-Editor in v1.2:** Create startet ohne Wiederholungs-UI
|
||||||
|
(einmalige Events). Ein einfacher Recurrence-Picker (täglich/wöchentlich/
|
||||||
|
monatlich/jährlich + Ende) kommt mit v1.3/v2.0.
|
||||||
|
5. **Conflict UX (Spec V2 "event modified externally during edit"):** kein
|
||||||
|
Locking. Beim Speichern wird gegen die beim Laden gemerkte Row verglichen
|
||||||
|
(Dirty-Check auf den editierten Feldern); bei externem Konflikt Dialog
|
||||||
|
"Überschreiben / Verwerfen". Mehr ist YAGNI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
| Slice | Inhalt | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
||||||
|
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
||||||
|
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | offen |
|
||||||
|
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
|
||||||
|
|
||||||
|
## v1.1 — Write-Fundament + Delete
|
||||||
|
|
||||||
|
**Build/Manifest:**
|
||||||
|
- [x] `AndroidManifest.xml`: `WRITE_CALENDAR` ergänzen
|
||||||
|
|
||||||
|
**Data layer:**
|
||||||
|
- [x] `Projections.kt`: `CALENDAR_ACCESS_LEVEL` in `CalendarProjection`
|
||||||
|
- [x] `Models.kt`: `CalendarSource.canModifyContents: Boolean` (Default `false`).
|
||||||
|
Kein neuer `FailureReason` — Delete-Fehler sind ein Snackbar-Fall, kein
|
||||||
|
Full-Screen-Failure
|
||||||
|
- [x] `CalendarMapper.kt`: Access-Level → `canModifyContents`
|
||||||
|
- [x] `CalendarDataSource`: `deleteEvent(eventId)`, `deleteOccurrence(eventId, beginMillis)`
|
||||||
|
— Impl in `AndroidCalendarDataSource` (`delete` auf Events-URI bzw.
|
||||||
|
Exception-Insert), `WriteFailedException` bei 0 rows / null-Uri
|
||||||
|
- [x] `CalendarRepository(+Impl)`: beide Methoden durchreichen, auf `io`
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [x] `EventDetailUiState.Success.canModify` (Kalender-Lookup im ViewModel)
|
||||||
|
- [x] `EventDetailViewModel`: `delete(mode)` mit eigenem One-Shot-State
|
||||||
|
(Idle/Deleting/Deleted/Failed); `SecurityException` → kontextueller
|
||||||
|
WRITE-Request statt Failure-Screen
|
||||||
|
- [x] `EventDetailScreen`: Edit/Delete nur wenn `canModify`; Delete →
|
||||||
|
Confirm-Dialog (recurring: "Nur dieser Termin" / "Ganze Serie"),
|
||||||
|
Erfolg → zurück, Fehler → Snackbar
|
||||||
|
- [x] Onboarding (`PermissionScreen`): `RequestMultiplePermissions` READ+WRITE,
|
||||||
|
Gate bleibt READ; Copy-Anpassung (Footnote, Rationale-Body) DE+EN
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
- [x] `FakeCalendarDataSource`: Write-Ops aufnehmen
|
||||||
|
- [x] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler)
|
||||||
|
- [x] `CalendarMapperTest`: Access-Level-Mapping
|
||||||
|
|
||||||
|
## v1.2 — Create
|
||||||
|
|
||||||
|
- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart,
|
||||||
|
NoCalendar; leerer Titel und Instant-Events erlaubt)
|
||||||
|
- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker
|
||||||
|
- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer,
|
||||||
|
Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag
|
||||||
|
- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt
|
||||||
|
(`CalendarPrefs.lastUsedCalendarId` statt Settings-Eintrag) > erster
|
||||||
|
beschreibbarer; Picker bietet nur beschreibbare Kalender an
|
||||||
|
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
|
||||||
|
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
|
||||||
|
|
||||||
|
## v1.3 — Edit (Skizze)
|
||||||
|
|
||||||
|
- Formular lädt `EventDetail`, Dirty-Check, `update` auf Events-Row
|
||||||
|
- Reminder hinzufügen/entfernen (`Reminders`-Insert/Delete)
|
||||||
|
- Einfacher Recurrence-Picker (FREQ + INTERVAL + UNTIL/COUNT)
|
||||||
|
|
||||||
|
## v2.0 — Abschluss (Skizze)
|
||||||
|
|
||||||
|
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
|
||||||
|
- Occurrence-Edit (Exception mit geänderten Werten)
|
||||||
|
- Konflikt-Dialog beim Speichern
|
||||||
|
- Changelog, F-Droid-Metadaten, Release-Tag
|
||||||
@@ -27,7 +27,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
|||||||
- 3 Hauptansichten: Monat, Woche, Tag
|
- 3 Hauptansichten: Monat, Woche, Tag
|
||||||
- Event-Detail-Sheet (read-only Detailansicht)
|
- Event-Detail-Sheet (read-only Detailansicht)
|
||||||
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
|
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
|
||||||
- Heute-Button + Jump-to-Date
|
- Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
|
||||||
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
|
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
|
||||||
- Permission-Flow für `READ_CALENDAR`
|
- Permission-Flow für `READ_CALENDAR`
|
||||||
- Empty-States und Error-Recovery
|
- Empty-States und Error-Recovery
|
||||||
@@ -35,6 +35,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
|||||||
- Tests + CI ab Tag 1
|
- Tests + CI ab Tag 1
|
||||||
|
|
||||||
### Out-of-Scope (V2+)
|
### Out-of-Scope (V2+)
|
||||||
|
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
|
||||||
- Event-Create/Edit/Delete (V2)
|
- Event-Create/Edit/Delete (V2)
|
||||||
- Home-Screen-Widget
|
- Home-Screen-Widget
|
||||||
- Volltextsuche
|
- Volltextsuche
|
||||||
@@ -202,9 +203,9 @@ fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
|
|||||||
- Immer erreichbar von allen Hauptansichten
|
- Immer erreichbar von allen Hauptansichten
|
||||||
- State persistent (zuletzt aktive Ansicht)
|
- State persistent (zuletzt aktive Ansicht)
|
||||||
|
|
||||||
**M2 - Heute / Springe-zu-Datum**
|
**M2 - Heute**
|
||||||
- Schnell zurück zu "heute"
|
- Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
|
||||||
- Springe zu beliebigem Datum via Datum-Picker
|
- ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
|
||||||
- Erreichbar von allen Hauptansichten
|
- Erreichbar von allen Hauptansichten
|
||||||
|
|
||||||
**M3 - Kalender-Filter (Bottom-Sheet)**
|
**M3 - Kalender-Filter (Bottom-Sheet)**
|
||||||
|
|||||||
Reference in New Issue
Block a user