Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 301f105fbc | |||
| f0e2e12939 | |||
| bdedf47972 | |||
| a69be3da43 | |||
| 779fa1d480 | |||
| c59a071b82 | |||
| 285bfd90a7 | |||
| 9529f19c60 | |||
| 0013c9f3b1 | |||
| bd6ad4ae5f | |||
| 3697a58e5b |
@@ -6,7 +6,11 @@ on:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
# Cancel superseded runs on the same branch.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -26,30 +30,25 @@ jobs:
|
||||
|
||||
- name: Setup Android SDK
|
||||
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
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"platforms;android-37.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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -63,16 +62,19 @@ jobs:
|
||||
- name: Grant execute permission for 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)
|
||||
run: ./gradlew lintDebug --no-daemon
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest --no-daemon
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Trivy filesystem scan
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
set -e
|
||||
SUDO=""
|
||||
|
||||
@@ -24,16 +24,33 @@ jobs:
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"platforms;android-37.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
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
@@ -42,10 +59,10 @@ jobs:
|
||||
# any tag-resolved drift (e.g. version code substitution issues).
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest --no-daemon
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug APK (sanity)
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
build-and-deploy:
|
||||
needs: ci
|
||||
@@ -65,16 +82,33 @@ jobs:
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"platforms;android-37.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
|
||||
run: |
|
||||
set -e
|
||||
@@ -121,7 +155,7 @@ jobs:
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease --no-daemon
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Setup F-Droid Server Tools
|
||||
run: |
|
||||
|
||||
@@ -30,7 +30,10 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
||||
- Home-screen widget
|
||||
- Full-text search
|
||||
- Quick-add
|
||||
- Custom notifications/reminders (system already handles these)
|
||||
- ~~Custom notifications/reminders (system already handles these)~~ —
|
||||
**reversed:** Calendula targets sole-calendar-app users, so no other app
|
||||
posts reminder notifications. We post them ourselves (Etar model). Planned
|
||||
for v1.4 — see `ROADMAP.md`.
|
||||
- Tablet/foldable-specific layouts
|
||||
- iOS support (Android-only by design)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||
| v0.6 | Full event read — surface every readable field | complete |
|
||||
| v1.0 | Polish pass, F-Droid release | pending |
|
||||
| v1.0 | First public release — polish pass, F-Droid | complete |
|
||||
|
||||
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||
@@ -44,20 +44,54 @@ Deliberately out of v0.6:
|
||||
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
||||
(provider limitation, not our choice)
|
||||
|
||||
## v1.0 — First Public Release
|
||||
## v1.0 — First Public Release — shipped 2026-06-11
|
||||
|
||||
All V1 features shipped, polished, on F-Droid. Read-only calendar.
|
||||
Remaining before v1.0: a UI polish/QA pass.
|
||||
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 on the v0.6.0 branch)
|
||||
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||
|
||||
## v2.0 — Write Support
|
||||
## v2.0 — Write Support (in progress)
|
||||
|
||||
- Event create / edit / delete via `CalendarContract` writes
|
||||
- Quick-add sheet
|
||||
- Conflict UX (event modified externally during edit)
|
||||
Delivered in four releasable slices (plan:
|
||||
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||
guide here, not a contract — scope per slice is decided as we go.
|
||||
|
||||
| Version | Milestone | Status |
|
||||
|---|---|---|
|
||||
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
||||
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||
| v1.4 | Reminder notifications — see below | planned |
|
||||
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
||||
|
||||
## v1.4 — Reminder Notifications
|
||||
|
||||
**Essential**, not nice-to-have: Calendula targets users for whom it is their
|
||||
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
|
||||
Calendar. The calendar provider schedules reminders and broadcasts
|
||||
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
|
||||
notification — a calendar app must. We become that app (the Etar model).
|
||||
|
||||
Scope:
|
||||
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
|
||||
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
|
||||
no foreground service.
|
||||
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
|
||||
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
|
||||
notification channel; tap opens event detail.
|
||||
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
|
||||
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
|
||||
toggle, **default ON**, with copy warning that a second calendar app with
|
||||
notifications on will cause duplicate reminders. Mirrored into Settings
|
||||
(reversible).
|
||||
|
||||
Deliberately deferred (add only if needed):
|
||||
- Snooze / dismiss notification actions (Etar has them)
|
||||
- Battery-optimization exemption prompt for delivery reliability
|
||||
|
||||
## v3.0 — Power-User Features
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** v0.6 — Full event read (complete)
|
||||
**Phase:** All V1 screens done and the read model is now complete — the detail
|
||||
view surfaces every readable `CalendarContract` field. Next up is a UI
|
||||
polish/QA pass before v1.0
|
||||
**Milestone:** v2.0 — Write support (milestone 2, in progress)
|
||||
**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device
|
||||
review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip,
|
||||
stale-instances split bugfix). Milestone 2 runs in four slices
|
||||
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add,
|
||||
conflict dialog, polish) is the remaining slice, v1.4 (reminder
|
||||
notifications) comes first.
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -28,7 +31,41 @@ polish/QA pass before v1.0
|
||||
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
||||
URL field was cut — no `CalendarContract` column backs it.)
|
||||
|
||||
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
|
||||
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
|
||||
detection (`CALENDAR_ACCESS_LEVEL` → `canModifyContents`, actions hidden for
|
||||
WebCal/birthday calendars), delete from the detail screen (recurring:
|
||||
"only this event" via cancelled exception / "all events in the series"),
|
||||
repository + mapper tests
|
||||
|
||||
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
|
||||
M3 date/time pickers with duration-preserving start moves, writable-only
|
||||
calendar picker preselecting the last-used calendar, location, description),
|
||||
"+" FAB on all three views prefilled with the visible day, `insertEvent`
|
||||
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
||||
domain/mapper/repository tests
|
||||
|
||||
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
|
||||
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
|
||||
upgrade), dirty-checked partial `update` on the Events row (recurring:
|
||||
series DTSTART moves by the user's delta, DURATION instead of DTEND),
|
||||
reminder diff by minutes (kept rows keep their method), simple recurrence
|
||||
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
|
||||
shown humanized), `EventFormField.Recurrence` incl. settings default,
|
||||
recurrence also available on create; domain/mapper/repository tests.
|
||||
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
|
||||
on Mon+Fri"). Review rounds 2–4: occurrence edit pulled forward from v2.0
|
||||
and made three-way like delete ("this" = exception row via
|
||||
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
|
||||
series update); delete equally three-way (truncation via RRULE UNTIL);
|
||||
the edit-scope question moved to save time (Google model) — dirty
|
||||
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
|
||||
the "only this event" option
|
||||
|
||||
## Next
|
||||
|
||||
1. UI polish / QA pass across all views before v1.0
|
||||
2. F-Droid release of v1.0
|
||||
1. v1.4 — reminder notifications (essential for sole-app use): `EVENT_REMINDER`
|
||||
receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with
|
||||
default-on toggle + duplicate-reminder warning (Etar model)
|
||||
2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release
|
||||
3. Monitor the F-Droid build/publish for v1.1.0 – v1.3.0
|
||||
|
||||
140
CHANGELOG.md
140
CHANGELOG.md
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.0] — 2026-06-11
|
||||
|
||||
### Added
|
||||
- Event editing: a pencil action on the detail screen (writable calendars
|
||||
only) opens the event form prefilled with the event. Only fields you
|
||||
actually changed are written back; saving an untouched form is a no-op.
|
||||
Sections holding data are always shown, regardless of the form-field
|
||||
defaults; the calendar itself can't be changed while editing
|
||||
- Recurring events — scoped writes, chosen when saving (Google model):
|
||||
"only this event" (a modified-occurrence exception), "this and all
|
||||
following" (the series is split at the occurrence), or "all events in
|
||||
the series". Changing the recurrence rule rules out "only this event"
|
||||
- Deleting a recurring event gained the middle option too: "this and all
|
||||
following events" ends the series just before the chosen occurrence
|
||||
- Recurrence picker (create and edit): one-tap daily/weekly/monthly/yearly
|
||||
presets plus a custom step with interval + unit, weekday toggles for
|
||||
weekly rules ("every week on Mon and Fri"), and an end condition (never /
|
||||
on a date / after a number of times). Rules the picker can't express
|
||||
(e.g. "second Thursday monthly") are shown humanized and preserved
|
||||
verbatim unless replaced. Recurrence also joined the optional form
|
||||
fields and their settings defaults
|
||||
- Validation: a repeat that would end before the event starts is flagged
|
||||
(it would otherwise vanish from every view)
|
||||
|
||||
### Changed
|
||||
- Editing reminders reconciles against the provider's actual rows:
|
||||
reminders you didn't touch keep their method (e.g. email reminders on
|
||||
synced events survive unrelated edits)
|
||||
- The contextual WRITE_CALENDAR upgrade for v1.0 installs covers the edit
|
||||
action like delete
|
||||
|
||||
### Fixed
|
||||
- Splitting a series ("this and following") sends the complete time-column
|
||||
set in one update, so the provider regenerates its cached instances — an
|
||||
RRULE-only update left a stale duplicate of the tapped occurrence on the
|
||||
split day
|
||||
- RRULE UNTIL values are written as the local end of day expressed in UTC
|
||||
(instead of a flat `T235959Z`), so recurrences can't leak an extra day in
|
||||
timezones ahead of UTC
|
||||
- `versionName`/`versionCode` bumped to 1.3.0 / 11
|
||||
|
||||
## [1.2.1] — 2026-06-11
|
||||
|
||||
### Added
|
||||
- Optional event-form fields with user-controlled defaults: reminders,
|
||||
availability (busy/free), and visibility (default/public/private/
|
||||
confidential) joined location and description as form sections. Settings
|
||||
gained a "New event form" section choosing which show by default; the rest
|
||||
unfold via a "More fields" picker
|
||||
- Reminders editor: stacked rows with right-bound remove, full-width add
|
||||
action; the picker offers one-tap presets and a custom amount + unit
|
||||
(minutes/hours/days/weeks) step
|
||||
- `OptionCard` — the app's standard selection-dialog row (full-width tonal
|
||||
card, optional icon + supporting line, highlighted selection). All dialogs
|
||||
(calendar, visibility, more-fields, reminder presets, recurring-delete)
|
||||
now use it; radio-row dialogs are retired
|
||||
|
||||
### Changed
|
||||
- Event form redesigned onto the detail screen's design system: tonal cards
|
||||
with gutter icons (top-aligned on tall cards), borderless inline text
|
||||
fields, calendar-coloured accent bar under the title, no dividers, no
|
||||
top-bar title; placeholders render clearly fainter than input
|
||||
- M3 Expressive motion: the theme now provides a MotionScheme
|
||||
(`MaterialExpressiveTheme`, standard springs — expressive bounce reviewed
|
||||
as overdone), the FAB stack and "more fields" reveals animate on theme
|
||||
springs
|
||||
- The jump-to-today slide is direction-aware (future → today slides in from
|
||||
the left, past → from the right)
|
||||
- `versionName`/`versionCode` bumped to 1.2.1 / 10
|
||||
|
||||
### Fixed
|
||||
- The keyboard no longer pans the whole event form; the screen stays
|
||||
anchored and the focused field scrolls into view (`adjustResize` +
|
||||
`imePadding`)
|
||||
|
||||
## [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
|
||||
|
||||
@@ -23,8 +23,8 @@ android {
|
||||
applicationId = "de.jeanlucmakiola.calendula"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 6
|
||||
versionName = "0.6.0"
|
||||
versionCode = 11
|
||||
versionName = "1.3.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
|
||||
<application
|
||||
android:name=".CalendulaApp"
|
||||
@@ -17,7 +18,8 @@
|
||||
tools:targetApi="35">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -2,18 +2,24 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -29,6 +35,57 @@ interface CalendarDataSource {
|
||||
fun calendars(): List<CalendarSource>
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
fun eventDetail(eventId: Long): EventDetail?
|
||||
|
||||
/** Insert a new event; returns the new `Events._ID`. */
|
||||
fun insertEvent(form: EventForm): Long
|
||||
|
||||
/**
|
||||
* Update an existing event (for recurring events: the whole series) to
|
||||
* match [updated]. [original] is the form as it was prefilled from the
|
||||
* event, so only fields the user actually changed are written and the
|
||||
* reminder rows can be diffed instead of wiped.
|
||||
*/
|
||||
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||
* row's `Events._ID`.
|
||||
*/
|
||||
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||
|
||||
/**
|
||||
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||
* by splitting the series: the existing RRULE ends just before the
|
||||
* occurrence and a new event with [updated]'s values (and rule) starts
|
||||
* there; returns the new event's `Events._ID`. From the first occurrence
|
||||
* this is a plain series update. A carried-over COUNT restarts counting
|
||||
* in the new series (we don't recompute the remaining occurrences).
|
||||
*/
|
||||
fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Delete a recurring event from the occurrence at [beginMillis] onwards
|
||||
* by ending the series RRULE just before it. Deleting from the first
|
||||
* occurrence removes the whole event.
|
||||
*/
|
||||
fun deleteEventFromOccurrence(eventId: Long, beginMillis: 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 unregisterChangeListener(listener: () -> Unit)
|
||||
}
|
||||
@@ -74,6 +131,252 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
||||
// (and no DTEND), one-off rows carry DTEND.
|
||||
if (form.rrule == null) {
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, form.rrule)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
}
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
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}")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Best effort (spec §8): the event exists at this point — a reminder
|
||||
// that fails to attach is logged, not surfaced as a failed create.
|
||||
form.reminders.distinct().forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
||||
val values = buildEventUpdateValues(
|
||||
original = original,
|
||||
updated = updated,
|
||||
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
if (values.isNotEmpty()) {
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(),
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||
}
|
||||
// Untouched reminder sets are left alone so unrelated edits can't
|
||||
// disturb provider rows the form never knew about.
|
||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||
reconcileReminders(eventId, updated.reminders)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
||||
// The provider clones the series row and applies these values on top.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form,
|
||||
originalInstanceMillis = beginMillis,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
val uri = resolver.insert(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId),
|
||||
values.toContentValues(),
|
||||
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
|
||||
val exceptionId = ContentUris.parseId(uri)
|
||||
// Whether the provider copied the parent's reminder rows is its
|
||||
// business — reconciling against the actual rows handles both ways.
|
||||
reconcileReminders(exceptionId, form.reminders)
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
override fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long {
|
||||
val row = querySeriesRow(eventId)
|
||||
// From the first occurrence on (or with no rule to split) this is
|
||||
// just a series update.
|
||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||
updateEvent(eventId, original, updated)
|
||||
return eventId
|
||||
}
|
||||
// Insert the new series first: if it fails, the original is untouched.
|
||||
val newEventId = insertEvent(updated)
|
||||
truncateSeries(eventId, row, beginMillis)
|
||||
return newEventId
|
||||
}
|
||||
|
||||
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||
val row = querySeriesRow(eventId)
|
||||
// From the first occurrence on = the whole series; also the fallback
|
||||
// when there is no RRULE to truncate.
|
||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||
deleteEvent(eventId)
|
||||
return
|
||||
}
|
||||
truncateSeries(eventId, row, beginMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
* End [row]'s series just before the occurrence at [beginMillis]. The
|
||||
* provider regenerates an event's cached instances only from the values
|
||||
* carried by the update itself — an RRULE-only update leaves the old
|
||||
* instances standing (observed on-device: the truncated occurrence kept
|
||||
* showing) — so the entire time-related set travels together, with only
|
||||
* the RRULE actually changing.
|
||||
*/
|
||||
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
|
||||
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||
put(CalendarContract.Events.DURATION, row.duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||
put(
|
||||
CalendarContract.Events.RRULE,
|
||||
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
|
||||
)
|
||||
}
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values,
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) {
|
||||
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
}
|
||||
|
||||
/** The series anchor: every time-related column of the Events row. */
|
||||
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
arrayOf(
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
SeriesRow(
|
||||
dtStartMillis = c.getLong(0),
|
||||
rrule = c.getString(1),
|
||||
timezone = c.getString(2),
|
||||
duration = c.getString(3),
|
||||
allDay = c.getInt(4),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: throw WriteFailedException("read series row of event id=$eventId")
|
||||
|
||||
private data class SeriesRow(
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
val timezone: String?,
|
||||
val duration: String?,
|
||||
val allDay: Int,
|
||||
) {
|
||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||
beginMillis = beginMillis,
|
||||
zone = runCatching { ZoneId.of(timezone ?: "UTC") }.getOrDefault(ZoneOffset.UTC),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the event's reminder rows match [targetMinutes]: rows with other
|
||||
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
||||
* (like insertEvent). Rows whose minutes survive keep their method.
|
||||
*/
|
||||
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||
val target = targetMinutes.toSet()
|
||||
val existing = queryReminders(eventId).map { it.minutes }.toSet()
|
||||
(existing - target).forEach { minutes ->
|
||||
resolver.delete(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
CalendarContract.Reminders.EVENT_ID + " = ? AND " +
|
||||
CalendarContract.Reminders.MINUTES + " = ?",
|
||||
arrayOf(eventId.toString(), minutes.toString()),
|
||||
)
|
||||
}
|
||||
(target - existing).forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||
ContentValues().also { cv ->
|
||||
forEach { (column, value) ->
|
||||
when (value) {
|
||||
null -> cv.putNull(column)
|
||||
is String -> cv.put(column, value)
|
||||
is Long -> cv.put(column, value)
|
||||
is Int -> cv.put(column, value)
|
||||
else -> error("Unsupported value for $column: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
@@ -119,4 +422,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
|
||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarDataSource"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
|
||||
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
||||
@@ -10,4 +11,6 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
||||
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
|
||||
color = getInt(CalendarProjection.IDX_COLOR),
|
||||
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.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Instant
|
||||
@@ -10,7 +11,46 @@ interface CalendarRepository {
|
||||
fun calendars(): Flow<List<CalendarSource>>
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
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
|
||||
|
||||
/**
|
||||
* Update an event (recurring: the whole series) from a validated form.
|
||||
* [original] is the prefilled form, used to write only what changed.
|
||||
*/
|
||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
*/
|
||||
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||
|
||||
/**
|
||||
* Change a recurring event from [beginMillis] onwards (series split);
|
||||
* returns the new event's `Events._ID`.
|
||||
*/
|
||||
suspend fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: 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)
|
||||
|
||||
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
|
||||
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||
}
|
||||
|
||||
class NoSuchEventException(eventId: Long) :
|
||||
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.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -68,6 +69,50 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
dataSource.insertEvent(form)
|
||||
}
|
||||
|
||||
override suspend fun updateEvent(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) = withContext(io) {
|
||||
dataSource.updateEvent(eventId, original, updated)
|
||||
}
|
||||
|
||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
form: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.updateOccurrence(eventId, beginMillis, form)
|
||||
}
|
||||
|
||||
override suspend fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
|
||||
}
|
||||
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
override suspend fun deleteEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
) = withContext(io) {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||
|
||||
@@ -42,8 +42,9 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
rawEnd
|
||||
}
|
||||
|
||||
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||
// localized placeholder, and the edit form must prefill the true value.
|
||||
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
||||
|
||||
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Instant
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
* events, seconds otherwise.
|
||||
*/
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||
} else {
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty-checked column values for updating an existing Events row: only what
|
||||
* the user actually changed is written, so untouched fields can't stomp
|
||||
* concurrent external edits. Keys are `CalendarContract.Events` columns; a
|
||||
* null value means "set the column to NULL". An empty map means nothing on
|
||||
* the row changed.
|
||||
*
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
||||
* past occurrences intact when someone edits a later occurrence's time.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
seriesDtStartMillis: Long,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
if (updated.title.trim() != original.title.trim()) {
|
||||
put(CalendarContract.Events.TITLE, updated.title.trim())
|
||||
}
|
||||
if (updated.location.trim() != original.location.trim()) {
|
||||
put(CalendarContract.Events.EVENT_LOCATION, updated.location.trim().ifEmpty { null })
|
||||
}
|
||||
if (updated.description.trim() != original.description.trim()) {
|
||||
put(CalendarContract.Events.DESCRIPTION, updated.description.trim().ifEmpty { null })
|
||||
}
|
||||
if (updated.availability != original.availability) {
|
||||
put(CalendarContract.Events.AVAILABILITY, updated.availability.toProviderValue())
|
||||
}
|
||||
if (updated.accessLevel != original.accessLevel) {
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
||||
}
|
||||
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
put(CalendarContract.Events.ALL_DAY, if (updated.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, newTimes.timezone)
|
||||
if (updated.rrule == null) {
|
||||
put(CalendarContract.Events.DTSTART, newTimes.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
} else {
|
||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
||||
put(CalendarContract.Events.DTEND, null)
|
||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a modified-occurrence exception row ("edit only this
|
||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
originalInstanceMillis: Long,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
val times = form.toWriteTimes(zone)
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||
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)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC millis of the last second of the local day *before* the occurrence at
|
||||
* [beginMillis] in [zone] — the cutoff for truncating a series with UNTIL.
|
||||
* The provider's recurrence engine applies UNTIL coarsely (observed on a
|
||||
* Pixel: an occurrence one second *after* UNTIL was still generated), so the
|
||||
* series must end on the previous day, not one second before the occurrence.
|
||||
* With no sub-daily frequencies that is semantically the same cut.
|
||||
*/
|
||||
internal fun previousLocalDayEndUtcMillis(beginMillis: Long, zone: ZoneId): Long =
|
||||
Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate()
|
||||
.atStartOfDay(zone).toInstant().toEpochMilli() - 1_000L
|
||||
|
||||
private const val MILLIS_PER_DAY = 86_400_000L
|
||||
|
||||
internal fun Availability.toProviderValue(): Int = when (this) {
|
||||
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
|
||||
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
|
||||
Availability.Tentative -> CalendarContract.Events.AVAILABILITY_TENTATIVE
|
||||
}
|
||||
|
||||
internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
||||
AccessLevel.Default -> CalendarContract.Events.ACCESS_DEFAULT
|
||||
AccessLevel.Confidential -> CalendarContract.Events.ACCESS_CONFIDENTIAL
|
||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||
}
|
||||
@@ -10,6 +10,7 @@ internal object CalendarProjection {
|
||||
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||
CalendarContract.Calendars.VISIBLE,
|
||||
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -18,6 +19,7 @@ internal object CalendarProjection {
|
||||
const val IDX_ACCOUNT_TYPE = 3
|
||||
const val IDX_COLOR = 4
|
||||
const val IDX_VISIBLE = 5
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.prefs
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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 {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -67,10 +68,38 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional event-form fields shown by default (the rest hide behind
|
||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||
* the factory default, an empty string means "none". Unknown names are
|
||||
* dropped defensively, like the other enum prefs.
|
||||
*/
|
||||
val defaultFormFields: Flow<Set<EventFormField>> = store.data.map { prefs ->
|
||||
parseFormFields(prefs[FORM_FIELDS_KEY])
|
||||
}
|
||||
|
||||
suspend fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||
store.edit { prefs ->
|
||||
val current = parseFormFields(prefs[FORM_FIELDS_KEY])
|
||||
val updated = if (enabled) current + field else current - field
|
||||
prefs[FORM_FIELDS_KEY] = updated.joinToString(",") { it.name }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
.mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val DEFAULT_FORM_FIELDS =
|
||||
setOf(EventFormField.Location, EventFormField.Description)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* 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 = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val availability: Availability = Availability.Busy,
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
/**
|
||||
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
|
||||
* means a one-off event. May hold rules the simple picker can't express —
|
||||
* those are kept verbatim until the user picks something else.
|
||||
*/
|
||||
val rrule: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The form's optional sections. Which ones show by default is a user setting;
|
||||
* the rest unfold behind a "more fields" button.
|
||||
*/
|
||||
enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
Visibility,
|
||||
}
|
||||
|
||||
enum class EventFormProblem {
|
||||
/** No target calendar — none picked and no writable calendar exists. */
|
||||
NoCalendar,
|
||||
EndBeforeStart,
|
||||
/** The recurrence's UNTIL date lies before the event's first day. */
|
||||
RecurrenceEndsBeforeStart,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/**
|
||||
* Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the
|
||||
* tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series
|
||||
* start — the data layer later turns a time edit into a delta on the series.
|
||||
*
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*/
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
title = instance.title,
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
availability = availability,
|
||||
accessLevel = accessLevel,
|
||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional sections that hold a value in [form] — when editing, these
|
||||
* must be visible regardless of the user's default-fields setting, or the
|
||||
* data they carry would be invisible (though still preserved).
|
||||
*/
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||
}
|
||||
|
||||
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)
|
||||
// An UNTIL before the first day would make the provider generate zero
|
||||
// occurrences — the event would silently vanish from every view.
|
||||
val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end
|
||||
if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) {
|
||||
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ data class CalendarSource(
|
||||
val accountType: String,
|
||||
val color: Int,
|
||||
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(
|
||||
@@ -109,6 +115,16 @@ enum class AccessLevel {
|
||||
Confidential,
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a write to a recurring event reaches. Non-recurring events always
|
||||
* use [AllEvents] (there is only one).
|
||||
*/
|
||||
enum class RecurringWriteScope {
|
||||
ThisEvent,
|
||||
ThisAndFollowing,
|
||||
AllEvents,
|
||||
}
|
||||
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The recurrence shapes the simple picker can express (v1.3): a frequency,
|
||||
* an interval, weekly weekday picks, and an optional end. Anything beyond
|
||||
* that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw
|
||||
* RRULE string the picker shows as "custom" and leaves untouched unless the
|
||||
* user replaces it.
|
||||
*/
|
||||
data class SimpleRecurrence(
|
||||
val freq: RecurrenceFreq,
|
||||
val interval: Int = 1,
|
||||
val end: RecurrenceEnd = RecurrenceEnd.Never,
|
||||
/**
|
||||
* Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means
|
||||
* no BYDAY part — the provider derives the day from DTSTART.
|
||||
*/
|
||||
val byDays: Set<DayOfWeek> = emptySet(),
|
||||
)
|
||||
|
||||
enum class RecurrenceFreq {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
sealed interface RecurrenceEnd {
|
||||
data object Never : RecurrenceEnd
|
||||
|
||||
/** Last day on which an occurrence may fall (inclusive). */
|
||||
data class Until(val date: LocalDate) : RecurrenceEnd
|
||||
|
||||
/** Total number of occurrences, counting the first. */
|
||||
data class Count(val times: Int) : RecurrenceEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an RRULE into the picker's simple shape, or null when the rule uses
|
||||
* parts the picker can't represent (so the UI preserves the original string).
|
||||
* Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime
|
||||
* UNTIL is converted from UTC into [zone] before its date is taken, mirroring
|
||||
* [toRRule].
|
||||
*/
|
||||
fun parseSimpleRecurrence(
|
||||
rrule: String,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): SimpleRecurrence? {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';')
|
||||
.filter { it.isNotBlank() }
|
||||
.associate { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}
|
||||
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
|
||||
return null
|
||||
}
|
||||
|
||||
val freq = when (parts["FREQ"]?.uppercase()) {
|
||||
"DAILY" -> RecurrenceFreq.Daily
|
||||
"WEEKLY" -> RecurrenceFreq.Weekly
|
||||
"MONTHLY" -> RecurrenceFreq.Monthly
|
||||
"YEARLY" -> RecurrenceFreq.Yearly
|
||||
else -> return null
|
||||
}
|
||||
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
|
||||
|
||||
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
|
||||
// forms ("2TH" = second Thursday) and BYDAY on other frequencies are not.
|
||||
val byDays = parts["BYDAY"]?.let { raw ->
|
||||
if (freq != RecurrenceFreq.Weekly) return null
|
||||
raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet()
|
||||
} ?: emptySet()
|
||||
|
||||
val until = parts["UNTIL"]
|
||||
val count = parts["COUNT"]
|
||||
if (until != null && count != null) return null
|
||||
val end = when {
|
||||
until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null)
|
||||
count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null)
|
||||
else -> RecurrenceEnd.Never
|
||||
}
|
||||
return SimpleRecurrence(freq, interval, end, byDays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render as a provider-ready RRULE value (no "RRULE:" prefix —
|
||||
* `CalendarContract.Events.RRULE` stores the bare value). UNTIL is written as
|
||||
* the end of the chosen day *in [zone]*, expressed in UTC: the recurrence
|
||||
* engine has been observed applying UNTIL coarsely after converting it into
|
||||
* the event's timezone, so a plain `T235959Z` can leak one extra day for
|
||||
* zones ahead of UTC.
|
||||
*/
|
||||
fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString {
|
||||
append("FREQ=")
|
||||
append(
|
||||
when (freq) {
|
||||
RecurrenceFreq.Daily -> "DAILY"
|
||||
RecurrenceFreq.Weekly -> "WEEKLY"
|
||||
RecurrenceFreq.Monthly -> "MONTHLY"
|
||||
RecurrenceFreq.Yearly -> "YEARLY"
|
||||
},
|
||||
)
|
||||
if (interval > 1) append(";INTERVAL=$interval")
|
||||
if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) {
|
||||
append(";BYDAY=")
|
||||
append(
|
||||
byDays.sortedBy { it.isoDayNumber }
|
||||
.joinToString(",") { RRULE_DAY_CODES.getValue(it) },
|
||||
)
|
||||
}
|
||||
when (val e = end) {
|
||||
RecurrenceEnd.Never -> Unit
|
||||
is RecurrenceEnd.Until -> {
|
||||
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
|
||||
.toInstant(zone)
|
||||
.toLocalDateTime(TimeZone.UTC)
|
||||
append(
|
||||
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.month.number, utc.day,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
),
|
||||
)
|
||||
}
|
||||
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
|
||||
}
|
||||
}
|
||||
|
||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
DayOfWeek.MONDAY to "MO",
|
||||
DayOfWeek.TUESDAY to "TU",
|
||||
DayOfWeek.WEDNESDAY to "WE",
|
||||
DayOfWeek.THURSDAY to "TH",
|
||||
DayOfWeek.FRIDAY to "FR",
|
||||
DayOfWeek.SATURDAY to "SA",
|
||||
DayOfWeek.SUNDAY to "SU",
|
||||
)
|
||||
|
||||
/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */
|
||||
private fun rruleDay(token: String): DayOfWeek? =
|
||||
RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key
|
||||
|
||||
/**
|
||||
* End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing
|
||||
* UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives.
|
||||
* Used for "delete this and all following occurrences" — the caller passes a
|
||||
* moment just before the first occurrence to remove.
|
||||
*/
|
||||
fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
||||
val kept = rrule.removePrefix("RRULE:").split(';')
|
||||
.filter { it.isNotBlank() }
|
||||
.filterNot { part ->
|
||||
val key = part.substringBefore('=').trim().uppercase()
|
||||
key == "UNTIL" || key == "COUNT"
|
||||
}
|
||||
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
|
||||
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
until.year, until.month.number, until.day,
|
||||
until.hour, until.minute, until.second,
|
||||
)
|
||||
return (kept + untilPart).joinToString(";")
|
||||
}
|
||||
|
||||
/**
|
||||
* Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime
|
||||
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
|
||||
* a [toRRule]-rendered value round-trips to the day the user picked.
|
||||
*/
|
||||
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
|
||||
val date = LocalDate(
|
||||
raw.substring(0, 4).toInt(),
|
||||
raw.substring(4, 6).toInt(),
|
||||
raw.substring(6, 8).toInt(),
|
||||
)
|
||||
if (raw.length >= 15 && raw[8] == 'T') {
|
||||
val time = LocalTime(
|
||||
raw.substring(9, 11).toInt(),
|
||||
raw.substring(11, 13).toInt(),
|
||||
raw.substring(13, 15).toInt(),
|
||||
)
|
||||
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
|
||||
} else {
|
||||
date
|
||||
}
|
||||
}.getOrNull()
|
||||
@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
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.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
@@ -66,6 +67,24 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
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()
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||
// recurring events the form itself asks for the write scope at save
|
||||
// time. A saved edit closes the detail screen too: the occurrence the
|
||||
// user tapped may not exist anymore (time moved, recurrence changed), so
|
||||
// falling back to the auto-refreshing calendar is the only honest
|
||||
// destination.
|
||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
@@ -75,12 +94,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
@@ -88,6 +109,7 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,6 +126,44 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
beginMillis = key[1],
|
||||
endMillis = key[2],
|
||||
onBack = { detailKey = null },
|
||||
onEdit = {
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
onSaved = { createDateIso = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — slides over the detail screen.
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
editKey = key,
|
||||
onClose = { editKey = null },
|
||||
onSaved = {
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@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(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The app's standard pick in a selection dialog: a full-width tonal card,
|
||||
* optionally with a leading icon and a supporting line; the selected option
|
||||
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
||||
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
||||
*/
|
||||
@Composable
|
||||
fun OptionCard(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector? = null,
|
||||
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
||||
iconTint: Color = Color.Unspecified,
|
||||
supportingText: String? = null,
|
||||
selected: Boolean = false,
|
||||
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
||||
labelColor: Color = Color.Unspecified,
|
||||
) {
|
||||
val contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = when {
|
||||
iconTint.isSpecified -> iconTint
|
||||
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (labelColor.isSpecified) labelColor else contentColor,
|
||||
)
|
||||
if (supportingText != null) {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.icu.text.ListFormatter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||
* monthly/yearly BYDAY, etc.). Shared by the detail screen and the edit
|
||||
* form's repeat card.
|
||||
*/
|
||||
@Composable
|
||||
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}.toMap()
|
||||
|
||||
val freq = parts["FREQ"]?.uppercase()
|
||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||
val base = when (freq) {
|
||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||
}
|
||||
|
||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||
// The day names + their joined block are tracked so only the names (not the
|
||||
// commas/conjunction) can be italicised in the final string.
|
||||
val byDay = parts["BYDAY"]
|
||||
var dayNames: List<String>? = null
|
||||
var joinedDays: String? = null
|
||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||
if (days.isNotEmpty()) {
|
||||
val joined = ListFormatter.getInstance(locale).format(days)
|
||||
dayNames = days
|
||||
joinedDays = joined
|
||||
stringResource(R.string.recurrence_on_days, base, joined)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
|
||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||
val count = parts["COUNT"]?.toIntOrNull()
|
||||
val full = when {
|
||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||
else -> main
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(full)
|
||||
val names = dayNames
|
||||
val joined = joinedDays
|
||||
if (names != null && joined != null) {
|
||||
// Italicise each day name within the joined block only — leaving the
|
||||
// separators and conjunction ("und"/"and") in the regular style.
|
||||
val regionStart = full.indexOf(joined)
|
||||
if (regionStart >= 0) {
|
||||
val regionEnd = regionStart + joined.length
|
||||
var cursor = regionStart
|
||||
for (name in names) {
|
||||
val at = full.indexOf(name, cursor)
|
||||
if (at in regionStart until regionEnd) {
|
||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||
cursor = at + name.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
"MO" -> DayOfWeek.MONDAY
|
||||
"TU" -> DayOfWeek.TUESDAY
|
||||
"WE" -> DayOfWeek.WEDNESDAY
|
||||
"TH" -> DayOfWeek.THURSDAY
|
||||
"FR" -> DayOfWeek.FRIDAY
|
||||
"SA" -> DayOfWeek.SATURDAY
|
||||
"SU" -> DayOfWeek.SUNDAY
|
||||
else -> return null
|
||||
}
|
||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
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.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
@@ -28,12 +25,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -72,6 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
@@ -108,6 +104,7 @@ fun DayScreen(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
@@ -144,7 +141,15 @@ fun DayScreen(
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
val jumpToToday = {
|
||||
slideDir = when (val s = state) {
|
||||
is DayUiState.Success -> if (s.today < s.date) -1 else 1
|
||||
else -> 0
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -172,17 +177,12 @@ fun DayScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnToday,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.day_today_action)) },
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayText = stringResource(R.string.day_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = { onCreateEvent(date) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
DayContent(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.icu.text.ListFormatter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
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.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -38,24 +41,32 @@ 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.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -63,7 +74,6 @@ import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -79,12 +89,14 @@ import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.DayOfWeek
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
@@ -94,10 +106,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Read-only full-screen event detail (spec S4, realised as a navigation
|
||||
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
|
||||
* gesture and the top-bar arrow both return to the calendar. The only action is
|
||||
* tapping the location to open a maps intent.
|
||||
* Full-screen event detail (spec S4, realised as a navigation destination
|
||||
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
|
||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||
* event form for this occurrence — for recurring events the form asks how
|
||||
* far the change reaches when saving.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -106,16 +120,77 @@ fun EventDetailScreen(
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
viewModel.open(eventId, beginMillis, endMillis)
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
|
||||
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 tapped action.
|
||||
var pendingEdit by remember { mutableStateOf(false) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
||||
}
|
||||
pendingEdit = false
|
||||
}
|
||||
val hasWritePermission = {
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
val onDeleteClick = {
|
||||
if (hasWritePermission()) {
|
||||
showDeleteDialog = true
|
||||
} else {
|
||||
pendingEdit = false
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
val onEditClick = {
|
||||
if (hasWritePermission()) {
|
||||
onEdit()
|
||||
} else {
|
||||
pendingEdit = true
|
||||
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(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
@@ -128,18 +203,29 @@ fun EventDetailScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
|
||||
// Only writable calendars get actions — WebCal subscriptions,
|
||||
// birthday calendars etc. are read-only at the provider level.
|
||||
val s = state
|
||||
if (s is EventDetailUiState.Success && s.canModify) {
|
||||
IconButton(
|
||||
onClick = onEditClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.event_detail_edit),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
@@ -159,8 +245,84 @@ fun EventDetailScreen(
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||
}
|
||||
}
|
||||
|
||||
val loaded = state
|
||||
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
|
||||
DeleteEventDialog(
|
||||
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
|
||||
onConfirm = { scope ->
|
||||
showDeleteDialog = false
|
||||
viewModel.delete(scope)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation. Recurring events choose between cancelling just the
|
||||
* tapped occurrence (default), truncating the series from it onwards, and
|
||||
* removing the whole series.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteEventDialog(
|
||||
isRecurring: Boolean,
|
||||
onConfirm: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isRecurring) R.string.event_delete_recurring_title
|
||||
else R.string.event_delete_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (isRecurring) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { scope = RecurringWriteScope.ThisEvent },
|
||||
selected = scope == RecurringWriteScope.ThisEvent,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
||||
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { scope = RecurringWriteScope.AllEvents },
|
||||
selected = scope == RecurringWriteScope.AllEvents,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.event_delete_body))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
||||
) {
|
||||
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 EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||
val detail = state.detail
|
||||
@@ -580,116 +742,6 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||
* monthly/yearly BYDAY, etc.).
|
||||
*/
|
||||
@Composable
|
||||
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}.toMap()
|
||||
|
||||
val freq = parts["FREQ"]?.uppercase()
|
||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||
val base = when (freq) {
|
||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||
}
|
||||
|
||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||
// The day names + their joined block are tracked so only the names (not the
|
||||
// commas/conjunction) can be italicised in the final string.
|
||||
val byDay = parts["BYDAY"]
|
||||
var dayNames: List<String>? = null
|
||||
var joinedDays: String? = null
|
||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||
if (days.isNotEmpty()) {
|
||||
val joined = ListFormatter.getInstance(locale).format(days)
|
||||
dayNames = days
|
||||
joinedDays = joined
|
||||
stringResource(R.string.recurrence_on_days, base, joined)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
|
||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||
val count = parts["COUNT"]?.toIntOrNull()
|
||||
val full = when {
|
||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||
else -> main
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(full)
|
||||
val names = dayNames
|
||||
val joined = joinedDays
|
||||
if (names != null && joined != null) {
|
||||
// Italicise each day name within the joined block only — leaving the
|
||||
// separators and conjunction ("und"/"and") in the regular style.
|
||||
val regionStart = full.indexOf(joined)
|
||||
if (regionStart >= 0) {
|
||||
val regionEnd = regionStart + joined.length
|
||||
var cursor = regionStart
|
||||
for (name in names) {
|
||||
val at = full.indexOf(name, cursor)
|
||||
if (at in regionStart until regionEnd) {
|
||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||
cursor = at + name.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
"MO" -> DayOfWeek.MONDAY
|
||||
"TU" -> DayOfWeek.TUESDAY
|
||||
"WE" -> DayOfWeek.WEDNESDAY
|
||||
"TH" -> DayOfWeek.THURSDAY
|
||||
"FR" -> DayOfWeek.FRIDAY
|
||||
"SA" -> DayOfWeek.SATURDAY
|
||||
"SU" -> DayOfWeek.SUNDAY
|
||||
else -> return null
|
||||
}
|
||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an event's time into a primary line (date, or "All day") and an
|
||||
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||
|
||||
@@ -4,7 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
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 {
|
||||
data object Loading : EventDetailUiState
|
||||
@@ -13,5 +13,20 @@ sealed interface EventDetailUiState {
|
||||
val detail: EventDetail,
|
||||
/** Display name of the owning calendar, null if it can't be resolved. */
|
||||
val calendarName: String?,
|
||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||
val canModify: Boolean = false,
|
||||
) : 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
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -38,6 +41,9 @@ class EventDetailViewModel @Inject constructor(
|
||||
// Bumped by retry() to re-run the load for the same target.
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||
|
||||
val state: StateFlow<EventDetailUiState> =
|
||||
combine(_target, _reload) { target, _ -> target }
|
||||
.flatMapLatest { target ->
|
||||
@@ -72,6 +78,41 @@ class EventDetailViewModel @Inject constructor(
|
||||
_reload.value += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the open event. [scope] is meaningful only for recurring events
|
||||
* (one-off events always pass [RecurringWriteScope.AllEvents]). Result
|
||||
* lands in [deleteState]; the screen consumes it via [consumeDeleteResult].
|
||||
*/
|
||||
fun delete(scope: RecurringWriteScope) {
|
||||
val target = _target.value ?: return
|
||||
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = DeleteUiState.Deleting
|
||||
_deleteState.value = try {
|
||||
when (scope) {
|
||||
RecurringWriteScope.AllEvents ->
|
||||
repository.deleteEvent(target.eventId)
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||
RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(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 {
|
||||
val detail = repository.eventDetail(target.eventId)
|
||||
// The Events row holds the series start; replace it with this
|
||||
@@ -82,10 +123,13 @@ class EventDetailViewModel @Inject constructor(
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
val calendarName = repository.calendars().first()
|
||||
val calendar = repository.calendars().first()
|
||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||
?.displayName
|
||||
EventDetailUiState.Success(corrected, calendarName)
|
||||
EventDetailUiState.Success(
|
||||
detail = corrected,
|
||||
calendarName = calendar?.displayName,
|
||||
canModify = calendar?.canModifyContents == true,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
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,
|
||||
/** Optional sections currently rendered (settings defaults ∪ revealed). */
|
||||
val visibleFields: Set<EventFormField> = emptySet(),
|
||||
/**
|
||||
* Optional sections behind "more fields". Sections the current mode can't
|
||||
* offer at all (recurrence while editing a single occurrence) appear in
|
||||
* neither list.
|
||||
*/
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||
* then drops "only this event" (an exception row can't carry a rule).
|
||||
*/
|
||||
val recurrenceChanged: Boolean = false,
|
||||
)
|
||||
|
||||
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||
sealed interface SaveUiState {
|
||||
data object Idle : SaveUiState
|
||||
/** A dirty recurring event waits for the user to pick the write scope. */
|
||||
data object AwaitingScope : 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,302 @@
|
||||
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.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@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)
|
||||
// Fields added through the "more fields" picker; folds back on reset().
|
||||
// openForEdit seeds it with the sections that already hold values.
|
||||
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
|
||||
// Set while the form edits an existing event instead of composing a new one.
|
||||
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||
private val _loadFailed = MutableStateFlow(false)
|
||||
|
||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
|
||||
/**
|
||||
* The event being edited plus the form exactly as it was prefilled.
|
||||
* For recurring events the write scope is chosen at save time; the
|
||||
* tapped occurrence's [beginMillis] anchors occurrence-level writes.
|
||||
*/
|
||||
private data class EditTarget(
|
||||
val eventId: Long,
|
||||
val original: EventForm,
|
||||
val beginMillis: Long,
|
||||
)
|
||||
|
||||
private data class LocalInputs(
|
||||
val form: EventForm?,
|
||||
val saveState: SaveUiState,
|
||||
val showProblems: Boolean,
|
||||
val revealed: Set<EventFormField>,
|
||||
val editTarget: EditTarget?,
|
||||
)
|
||||
|
||||
private data class ExternalInputs(
|
||||
val writable: List<CalendarSource>,
|
||||
val lastUsed: Long?,
|
||||
val defaultFields: Set<EventFormField>,
|
||||
)
|
||||
|
||||
val state: StateFlow<EventEditUiState?> = combine(
|
||||
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
||||
combine(
|
||||
repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||
.catch { emit(emptyList()) },
|
||||
prefs.lastUsedCalendarId,
|
||||
settingsPrefs.defaultFormFields,
|
||||
::ExternalInputs,
|
||||
),
|
||||
) { local, external ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
?: external.writable.firstOrNull()?.id
|
||||
val resolved = form.copy(calendarId = resolvedId)
|
||||
val visibleFields = external.defaultFields + local.revealed
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = external.writable,
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
recurrenceChanged = local.editTarget != null &&
|
||||
resolved.rrule != local.editTarget.original.rrule,
|
||||
)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||
* form is open, so user edits survive configuration changes.
|
||||
*/
|
||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
viewModelScope.launch {
|
||||
val detail = try {
|
||||
repository.eventDetail(eventId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_loadFailed.value = true
|
||||
return@launch
|
||||
}
|
||||
val original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault())
|
||||
_editTarget.value = EditTarget(eventId, original, beginMillis)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = original.populatedFields()
|
||||
_form.value = original
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||
fun reset() {
|
||||
_form.value = null
|
||||
_saveState.value = SaveUiState.Idle
|
||||
_showProblems.value = false
|
||||
_revealed.value = emptySet()
|
||||
_editTarget.value = null
|
||||
_loadFailed.value = false
|
||||
}
|
||||
|
||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||
fun revealField(field: EventFormField) {
|
||||
_revealed.value = _revealed.value + field
|
||||
}
|
||||
|
||||
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) }
|
||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||
|
||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||
|
||||
fun addReminder(minutes: Int) = update {
|
||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
||||
}
|
||||
|
||||
fun removeReminder(minutes: Int) = update {
|
||||
it.copy(reminders = it.reminders - minutes)
|
||||
}
|
||||
|
||||
/** 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. Saving a dirty recurring event pauses in
|
||||
* [SaveUiState.AwaitingScope] until the screen answers via
|
||||
* [saveWithScope]; everything else writes directly. 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
|
||||
}
|
||||
val target = _editTarget.value
|
||||
if (target != null && form == target.original) {
|
||||
// A pristine form saves as a no-op instead of a write.
|
||||
_saveState.value = SaveUiState.Saved
|
||||
return
|
||||
}
|
||||
if (target != null && target.original.rrule != null) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
performSave(form, RecurringWriteScope.AllEvents)
|
||||
}
|
||||
|
||||
/** Finish a save parked in [SaveUiState.AwaitingScope]. */
|
||||
fun saveWithScope(scope: RecurringWriteScope) {
|
||||
val current = state.value ?: return
|
||||
if (current.saveState != SaveUiState.AwaitingScope) return
|
||||
performSave(current.form, scope)
|
||||
}
|
||||
|
||||
private fun performSave(form: EventForm, scope: RecurringWriteScope) {
|
||||
val target = _editTarget.value
|
||||
viewModelScope.launch {
|
||||
_saveState.value = SaveUiState.Saving
|
||||
_saveState.value = try {
|
||||
if (target == null) {
|
||||
repository.createEvent(form)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else {
|
||||
when (scope) {
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
repository.updateOccurrence(target.eventId, target.beginMillis, form)
|
||||
RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.updateEventFromOccurrence(
|
||||
eventId = target.eventId,
|
||||
beginMillis = target.beginMillis,
|
||||
original = target.original,
|
||||
updated = form,
|
||||
)
|
||||
RecurringWriteScope.AllEvents ->
|
||||
repository.updateEvent(target.eventId, target.original, form)
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.gestures.detectHorizontalDragGestures
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -62,6 +57,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
@@ -74,8 +70,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
@@ -86,6 +85,7 @@ fun MonthScreen(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -113,8 +113,14 @@ fun MonthScreen(
|
||||
slideDir = -1
|
||||
viewModel.goToPrev()
|
||||
}
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
val jumpToToday = {
|
||||
slideDir = 0
|
||||
slideDir = when (val s = state) {
|
||||
is MonthUiState.Success ->
|
||||
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
|
||||
else -> 0
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
|
||||
@@ -147,17 +153,20 @@ fun MonthScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnCurrentMonth,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.month_today_action)) },
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnCurrentMonth,
|
||||
todayText = stringResource(R.string.month_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Anchor on today when its month is shown, else the 1st.
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
onCreateEvent(
|
||||
if (isOnCurrentMonth) today
|
||||
else LocalDate(month.year, month.month, 1),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
|
||||
@@ -56,6 +56,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.foundation.Image
|
||||
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
|
||||
@@ -73,10 +78,17 @@ fun PermissionScreen(
|
||||
) {
|
||||
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(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) viewModel.onGranted() else viewModel.onDenied()
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { results ->
|
||||
if (results[Manifest.permission.READ_CALENDAR] == true) {
|
||||
viewModel.onGranted()
|
||||
} else {
|
||||
viewModel.onDenied()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
@@ -85,13 +97,13 @@ fun PermissionScreen(
|
||||
|
||||
when (state) {
|
||||
is PermissionUiState.Rationale -> RationaleContent(
|
||||
onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) },
|
||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Denied -> DeniedContent(
|
||||
onRetry = {
|
||||
viewModel.onRetry()
|
||||
launcher.launch(Manifest.permission.READ_CALENDAR)
|
||||
launcher.launch(CALENDAR_PERMISSIONS)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
|
||||
/**
|
||||
* Settings (M4) — appearance (theme, dynamic colour, week start), language,
|
||||
@@ -111,6 +112,22 @@ fun SettingsScreen(
|
||||
onSelect = viewModel::setWeekStart,
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_section_event_form))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_form_fields_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
EventFormField.entries.forEach { field ->
|
||||
FormFieldRow(
|
||||
title = stringResource(formFieldLabel(field)),
|
||||
checked = field in state.defaultFormFields,
|
||||
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_section_language))
|
||||
LanguageRow()
|
||||
@@ -298,6 +315,36 @@ private fun AboutRow(title: String, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormFieldRow(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
when (mode) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -14,4 +16,6 @@ data class SettingsUiState(
|
||||
val dynamicColor: Boolean = true,
|
||||
val dynamicColorAvailable: Boolean = true,
|
||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -26,12 +27,14 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.themeMode,
|
||||
prefs.dynamicColor,
|
||||
prefs.weekStart,
|
||||
) { theme, dynamic, weekStart ->
|
||||
prefs.defaultFormFields,
|
||||
) { theme, dynamic, weekStart, formFields ->
|
||||
SettingsUiState(
|
||||
themeMode = theme,
|
||||
dynamicColor = dynamic && dynamicColorAvailable,
|
||||
dynamicColorAvailable = dynamicColorAvailable,
|
||||
weekStart = weekStart,
|
||||
defaultFormFields = formFields,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -50,4 +53,8 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||
}
|
||||
|
||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -17,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||||
* but the V1 foundation just follows the system.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CalendulaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
@@ -32,9 +35,15 @@ fun CalendulaTheme(
|
||||
else -> CalendulaLightFallback
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
// MaterialExpressiveTheme routes all component + custom motion through
|
||||
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
|
||||
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
|
||||
// expressive(): same spring choreography, but without the overshoot —
|
||||
// the bouncy variant felt overdone in review (2026-06-11).
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = CalendulaTypography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
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.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
@@ -31,12 +28,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -77,6 +72,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
@@ -88,7 +84,10 @@ import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
@@ -113,6 +112,7 @@ fun WeekScreen(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -146,7 +146,15 @@ fun WeekScreen(
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
val jumpToToday = {
|
||||
slideDir = when (val s = state) {
|
||||
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
|
||||
else -> 0
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -174,17 +182,17 @@ fun WeekScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnCurrentWeek,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.week_today_action)) },
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnCurrentWeek,
|
||||
todayText = stringResource(R.string.week_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Anchor on today when it's in view, else the week's first day.
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
onCreateEvent(if (isOnCurrentWeek) today else weekStart)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie.</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">Kalender-Zugriff erlauben</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>
|
||||
@@ -25,7 +25,7 @@
|
||||
<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">Nur Lesezugriff · keine Internet-Berechtigung</string>
|
||||
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
||||
|
||||
<!-- Monatsansicht (S1) -->
|
||||
<string name="month_prev">Vorheriger Monat</string>
|
||||
@@ -47,6 +47,58 @@
|
||||
<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_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_following">Dieser und alle folgenden Termine</string>
|
||||
<string name="event_delete_option_series">Alle Termine der Serie</string>
|
||||
<string name="event_edit_recurring_title">Wiederkehrenden Termin bearbeiten</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_edit_more_fields">Weitere Felder</string>
|
||||
<string name="event_edit_add">Hinzufügen</string>
|
||||
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
||||
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
|
||||
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||
<string name="reminder_unit_minutes">Minuten</string>
|
||||
<string name="reminder_unit_hours">Stunden</string>
|
||||
<string name="reminder_unit_days">Tage</string>
|
||||
<string name="reminder_unit_weeks">Wochen</string>
|
||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||
|
||||
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||
<string name="event_edit_recurrence_every">Alle</string>
|
||||
<string name="recurrence_unit_days">Tage</string>
|
||||
<string name="recurrence_unit_weeks">Wochen</string>
|
||||
<string name="recurrence_unit_months">Monate</string>
|
||||
<string name="recurrence_unit_years">Jahre</string>
|
||||
<string name="event_edit_recurrence_ends">Endet</string>
|
||||
<string name="event_edit_recurrence_end_never">Nie</string>
|
||||
<string name="event_edit_recurrence_end_until">An einem Datum</string>
|
||||
<string name="event_edit_recurrence_end_count">Nach einer Anzahl</string>
|
||||
<string name="event_edit_recurrence_times">Mal</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Wiederholung endet vor dem Beginn</string>
|
||||
<string name="event_availability_busy">Beschäftigt</string>
|
||||
<string name="event_access_default">Standard</string>
|
||||
<string name="event_access_public">Öffentlich</string>
|
||||
<string name="event_detail_all_day">Ganztägig</string>
|
||||
<string name="event_detail_calendar">Kalender</string>
|
||||
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
||||
@@ -129,6 +181,8 @@
|
||||
<string name="settings_week_start_auto">Automatisch</string>
|
||||
<string name="settings_week_start_monday">Montag</string>
|
||||
<string name="settings_week_start_sunday">Sonntag</string>
|
||||
<string name="settings_section_event_form">Termin-Formular</string>
|
||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||
<string name="settings_section_language">Sprache</string>
|
||||
<string name="settings_language">App-Sprache</string>
|
||||
<string name="settings_language_auto">Systemstandard</string>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for.</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">Grant calendar access</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>
|
||||
@@ -26,7 +26,7 @@
|
||||
<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">Read-only · no internet permission</string>
|
||||
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
|
||||
|
||||
<!-- Month view (S1) -->
|
||||
<string name="month_prev">Previous month</string>
|
||||
@@ -48,6 +48,58 @@
|
||||
<string name="event_detail_back">Back</string>
|
||||
<string name="event_detail_edit">Edit</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_following">This and all following events</string>
|
||||
<string name="event_delete_option_series">All events in the series</string>
|
||||
<string name="event_edit_recurring_title">Edit recurring event</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_edit_more_fields">More fields</string>
|
||||
<string name="event_edit_add">Add</string>
|
||||
<string name="event_edit_add_reminder">Add reminder</string>
|
||||
<string name="event_edit_remove_reminder">Remove reminder</string>
|
||||
<string name="event_edit_reminder_custom">Custom</string>
|
||||
<string name="reminder_unit_minutes">minutes</string>
|
||||
<string name="reminder_unit_hours">hours</string>
|
||||
<string name="reminder_unit_days">days</string>
|
||||
<string name="reminder_unit_weeks">weeks</string>
|
||||
<string name="event_edit_availability">Availability</string>
|
||||
<string name="event_edit_visibility">Visibility</string>
|
||||
|
||||
<!-- Event form — recurrence picker (v1.3) -->
|
||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||
<string name="event_edit_recurrence_custom">Custom</string>
|
||||
<string name="event_edit_recurrence_every">Every</string>
|
||||
<string name="recurrence_unit_days">days</string>
|
||||
<string name="recurrence_unit_weeks">weeks</string>
|
||||
<string name="recurrence_unit_months">months</string>
|
||||
<string name="recurrence_unit_years">years</string>
|
||||
<string name="event_edit_recurrence_ends">Ends</string>
|
||||
<string name="event_edit_recurrence_end_never">Never</string>
|
||||
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||
<string name="event_edit_recurrence_end_count">After a number of times</string>
|
||||
<string name="event_edit_recurrence_times">times</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Repeats end before the event starts</string>
|
||||
<string name="event_availability_busy">Busy</string>
|
||||
<string name="event_access_default">Default</string>
|
||||
<string name="event_access_public">Public</string>
|
||||
<string name="event_detail_all_day">All day</string>
|
||||
<string name="event_detail_calendar">Calendar</string>
|
||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||
@@ -130,6 +182,8 @@
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_start_monday">Monday</string>
|
||||
<string name="settings_week_start_sunday">Sunday</string>
|
||||
<string name="settings_section_event_form">New event form</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_section_language">Language</string>
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -12,6 +13,7 @@ class CalendarMapperTest {
|
||||
accountType: String? = "LOCAL",
|
||||
color: Int = 0,
|
||||
visible: Int = 1,
|
||||
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
CalendarProjection.IDX_ID to id,
|
||||
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
||||
@@ -19,6 +21,7 @@ class CalendarMapperTest {
|
||||
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
|
||||
CalendarProjection.IDX_COLOR to color,
|
||||
CalendarProjection.IDX_VISIBLE to visible,
|
||||
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -39,6 +42,7 @@ class CalendarMapperTest {
|
||||
accountType = "com.google",
|
||||
color = 0xFF112233.toInt(),
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -65,4 +69,25 @@ class CalendarMapperTest {
|
||||
assertThat(src.accountName).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 de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -157,6 +161,172 @@ 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 `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val original = 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 updated = original.copy(title = "Daily")
|
||||
|
||||
repo.updateEvent(eventId = 42L, original = original, updated = updated)
|
||||
|
||||
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("update event id=42")
|
||||
}
|
||||
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.updateEvent(eventId = 42L, original = form, updated = form.copy(title = "X"))
|
||||
error("Expected WriteFailedException")
|
||||
} catch (expected: WriteFailedException) {
|
||||
assertThat(expected.message).contains("42")
|
||||
}
|
||||
}
|
||||
|
||||
@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 `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||
|
||||
assertThat(fake.deletedFromOccurrences).containsExactly(42L to 1_000L)
|
||||
assertThat(fake.deletedEventIds).isEmpty()
|
||||
assertThat(fake.deletedOccurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Moved",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
)
|
||||
|
||||
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
|
||||
|
||||
assertThat(id).isEqualTo(88L)
|
||||
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Weekly",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
rrule = "FREQ=WEEKLY",
|
||||
)
|
||||
val updated = original.copy(title = "Weekly, renamed")
|
||||
|
||||
val id = repo.updateEventFromOccurrence(
|
||||
eventId = 42L,
|
||||
beginMillis = 1_000L,
|
||||
original = original,
|
||||
updated = updated,
|
||||
)
|
||||
|
||||
assertThat(id).isEqualTo(99L)
|
||||
assertThat(fake.updatedFromOccurrences).containsExactly(Triple(42L, 1_000L, updated))
|
||||
assertThat(fake.updatedEvents).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
|
||||
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
|
||||
@@ -88,6 +88,12 @@ class EventDetailMapperTest {
|
||||
assertThat(detail.attendees).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing title stays raw so the edit form does not inherit a placeholder`() {
|
||||
assertThat(detailReader(title = null).toDetail()!!.instance.title).isEmpty()
|
||||
assertThat(detailReader(title = "").toDetail()!!.instance.title).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event color falls back to calendar color when null`() {
|
||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
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 `availability maps to the provider constants`() {
|
||||
assertThat(Availability.Busy.toProviderValue())
|
||||
.isEqualTo(CalendarContract.Events.AVAILABILITY_BUSY)
|
||||
assertThat(Availability.Free.toProviderValue())
|
||||
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
|
||||
assertThat(Availability.Tentative.toProviderValue())
|
||||
.isEqualTo(CalendarContract.Events.AVAILABILITY_TENTATIVE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `access level maps to the provider constants`() {
|
||||
assertThat(AccessLevel.Default.toProviderValue())
|
||||
.isEqualTo(CalendarContract.Events.ACCESS_DEFAULT)
|
||||
assertThat(AccessLevel.Private.toProviderValue())
|
||||
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
|
||||
assertThat(AccessLevel.Confidential.toProviderValue())
|
||||
.isEqualTo(CalendarContract.Events.ACCESS_CONFIDENTIAL)
|
||||
assertThat(AccessLevel.Public.toProviderValue())
|
||||
.isEqualTo(CalendarContract.Events.ACCESS_PUBLIC)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncation cutoff is the end of the previous local day`() {
|
||||
// June 9 2026 15:30Z == 17:30 in Berlin. The cutoff must be
|
||||
// June 8 23:59:59 Berlin == June 8 21:59:59Z.
|
||||
assertThat(previousLocalDayEndUtcMillis(1_781_019_000_000L, berlin))
|
||||
.isEqualTo(1_780_955_999_000L)
|
||||
// All-day series live in UTC: the cutoff for a June 9 UTC-midnight
|
||||
// occurrence is June 8 23:59:59Z.
|
||||
assertThat(previousLocalDayEndUtcMillis(1_780_963_200_000L, ZoneId.of("UTC")))
|
||||
.isEqualTo(1_780_963_199_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration renders seconds for timed and days for all-day events`() {
|
||||
assertThat(form().toWriteTimes(berlin).toRfc2445Duration(isAllDay = false))
|
||||
.isEqualTo("P5400S")
|
||||
assertThat(form(isAllDay = true).toWriteTimes(berlin).toRfc2445Duration(isAllDay = true))
|
||||
.isEqualTo("P1D")
|
||||
}
|
||||
|
||||
// --- buildEventUpdateValues (dirty-checked partial update) ---
|
||||
|
||||
private val seriesStart = 1_700_000_000_000L
|
||||
|
||||
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
|
||||
buildEventUpdateValues(original, updated, seriesStart, berlin)
|
||||
|
||||
@Test
|
||||
fun `pristine form produces no values`() {
|
||||
val original = form()
|
||||
assertThat(update(original, original.copy())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text-only edit writes just the changed columns`() {
|
||||
val original = form()
|
||||
val values = update(original, original.copy(title = "New", description = "Body"))
|
||||
assertThat(values).containsExactly(
|
||||
CalendarContract.Events.TITLE, "New",
|
||||
CalendarContract.Events.DESCRIPTION, "Body",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing location writes an explicit null`() {
|
||||
val original = form().copy(location = "Berlin")
|
||||
val values = update(original, original.copy(location = " "))
|
||||
assertThat(values).containsExactly(CalendarContract.Events.EVENT_LOCATION, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
|
||||
val original = form()
|
||||
val updated = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
|
||||
)
|
||||
val values = update(original, updated)
|
||||
// 2026-06-11 11:00 CEST == 09:00Z.
|
||||
assertThat(values[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(1_781_164_800_000L + 3_600_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 2L * 3_600_000L)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time edit on a recurring event moves the series start by the same delta`() {
|
||||
val original = form().copy(rrule = "FREQ=WEEKLY")
|
||||
val updated = original.copy(
|
||||
// Pushed one hour later than the displayed occurrence.
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||
)
|
||||
val values = update(original, updated)
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
|
||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
||||
val original = form()
|
||||
val values = update(original, original.copy(rrule = "FREQ=DAILY;COUNT=5"))
|
||||
// The event was one-off, so the row's DTSTART is the occurrence start
|
||||
// and a zero delta keeps it in place.
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart)
|
||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing the recurrence writes absolute occurrence times and clears the rule`() {
|
||||
val original = form().copy(rrule = "FREQ=WEEKLY")
|
||||
val values = update(original, original.copy(rrule = null))
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reminder-only changes touch no event columns`() {
|
||||
val original = form()
|
||||
assertThat(update(original, original.copy(reminders = listOf(10)))).isEmpty()
|
||||
}
|
||||
|
||||
// --- buildOccurrenceExceptionValues ("edit only this event") ---
|
||||
|
||||
@Test
|
||||
fun `occurrence exception carries absolute times and the original instance`() {
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form().copy(title = "Moved", location = "Berlin"),
|
||||
originalInstanceMillis = 1_700_000_000_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||
.isEqualTo(1_700_000_000_000L)
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
// A single occurrence never carries its own rule.
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occurrence exception clears empty optionals explicitly`() {
|
||||
// The provider clones the parent row, so a blank field must be an
|
||||
// explicit NULL or the parent's value would survive.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form(),
|
||||
originalInstanceMillis = 0L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var calendarsResult: List<CalendarSource> = emptyList()
|
||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||
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 updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val deletedEventIds = mutableListOf<Long>()
|
||||
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||
|
||||
private val listeners = mutableListOf<() -> Unit>()
|
||||
|
||||
@@ -20,6 +33,49 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instancesResult(beginMillis, endMillis)
|
||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||
|
||||
override fun insertEvent(form: EventForm): Long {
|
||||
writeError?.let { throw it }
|
||||
insertedForms += form
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
||||
writeError?.let { throw it }
|
||||
updatedEvents += Triple(eventId, original, updated)
|
||||
}
|
||||
|
||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
||||
writeError?.let { throw it }
|
||||
updatedOccurrences += Triple(eventId, beginMillis, form)
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedFromOccurrences += eventId to beginMillis
|
||||
}
|
||||
|
||||
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) {
|
||||
listeners += listener
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -60,6 +61,45 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `form fields default to location and description`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultFormFields.first()).containsExactly(
|
||||
EventFormField.Location,
|
||||
EventFormField.Description,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `form field toggle round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setFormFieldDefault(EventFormField.Reminders, enabled = true)
|
||||
prefs.setFormFieldDefault(EventFormField.Location, enabled = false)
|
||||
assertThat(prefs.defaultFormFields.first()).containsExactly(
|
||||
EventFormField.Description,
|
||||
EventFormField.Reminders,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling every form field persists as none, not factory default`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
EventFormField.entries.forEach { prefs.setFormFieldDefault(it, enabled = false) }
|
||||
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.FORM_FIELDS_KEY] = "Location,Hologram"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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 kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurrence until before the first day is a problem`() {
|
||||
// Days before the start, so it parses to an earlier date in any zone.
|
||||
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
|
||||
assertThat(bad.problems())
|
||||
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurrence until on or after the first day is fine`() {
|
||||
// Date-only UNTIL parses zone-independently.
|
||||
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
|
||||
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complex rrules are not validated against the start`() {
|
||||
// The picker can't have produced this ("second Monday" ordinal BYDAY);
|
||||
// it is preserved verbatim and never flagged.
|
||||
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly byday rules are validated against the start`() {
|
||||
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
|
||||
assertThat(bad.problems())
|
||||
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||
}
|
||||
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
|
||||
private fun detail(
|
||||
isAllDay: Boolean = false,
|
||||
title: String = "Stand-up",
|
||||
location: String? = "Berlin",
|
||||
description: String? = "Body",
|
||||
rrule: String? = null,
|
||||
reminders: List<Reminder> = emptyList(),
|
||||
availability: Availability = Availability.Busy,
|
||||
accessLevel: AccessLevel = AccessLevel.Default,
|
||||
): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 1L,
|
||||
eventId = 1L,
|
||||
calendarId = 7L,
|
||||
title = title,
|
||||
start = Instant.fromEpochMilliseconds(0L),
|
||||
end = Instant.fromEpochMilliseconds(0L),
|
||||
isAllDay = isAllDay,
|
||||
color = 0,
|
||||
location = location,
|
||||
),
|
||||
description = description,
|
||||
organizer = null,
|
||||
attendees = emptyList(),
|
||||
rrule = rrule,
|
||||
reminders = reminders,
|
||||
availability = availability,
|
||||
accessLevel = accessLevel,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `toEditForm prefills a timed event from the occurrence times`() {
|
||||
// 2026-06-11 is CEST (UTC+2): 08:00Z == 10:00 local.
|
||||
val prefilled = detail().toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
|
||||
assertThat(prefilled.isAllDay).isFalse()
|
||||
assertThat(prefilled.calendarId).isEqualTo(7L)
|
||||
assertThat(prefilled.title).isEqualTo("Stand-up")
|
||||
assertThat(prefilled.location).isEqualTo("Berlin")
|
||||
assertThat(prefilled.description).isEqualTo("Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||
val prefilled = detail(isAllDay = true).toEditForm(
|
||||
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
|
||||
endMillis = LocalDate(2026, 6, 14).toEpochDays() * 86_400_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.start.date).isEqualTo(LocalDate(2026, 6, 11))
|
||||
assertThat(prefilled.end.date).isEqualTo(LocalDate(2026, 6, 13))
|
||||
assertThat(prefilled.isAllDay).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm sorts and dedupes reminder minutes and strips an RRULE prefix`() {
|
||||
val prefilled = detail(
|
||||
rrule = "RRULE:FREQ=WEEKLY",
|
||||
reminders = listOf(
|
||||
Reminder(30, ReminderMethod.Email),
|
||||
Reminder(10, ReminderMethod.Alert),
|
||||
Reminder(30, ReminderMethod.Alert),
|
||||
),
|
||||
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||
assertThat(prefilled.reminders).containsExactly(10, 30).inOrder()
|
||||
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `populatedFields reports exactly the sections holding values`() {
|
||||
val empty = form().copy(location = "", description = "")
|
||||
assertThat(empty.populatedFields()).isEmpty()
|
||||
|
||||
val full = form().copy(
|
||||
location = "Berlin",
|
||||
description = "Body",
|
||||
reminders = listOf(10),
|
||||
rrule = "FREQ=DAILY",
|
||||
availability = Availability.Free,
|
||||
accessLevel = AccessLevel.Private,
|
||||
)
|
||||
assertThat(full.populatedFields()).containsExactly(
|
||||
EventFormField.Location,
|
||||
EventFormField.Description,
|
||||
EventFormField.Reminders,
|
||||
EventFormField.Recurrence,
|
||||
EventFormField.Availability,
|
||||
EventFormField.Visibility,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RecurrenceTest {
|
||||
|
||||
private val utc = TimeZone.UTC
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
|
||||
@Test
|
||||
fun `plain frequency parses with defaults`() {
|
||||
assertThat(parseSimpleRecurrence("FREQ=WEEKLY"))
|
||||
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly))
|
||||
assertThat(parseSimpleRecurrence("FREQ=DAILY"))
|
||||
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leading RRULE prefix and WKST are tolerated`() {
|
||||
assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO"))
|
||||
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interval parses`() {
|
||||
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2"))
|
||||
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `until parses date-only and UTC datetime forms`() {
|
||||
val expected = SimpleRecurrence(
|
||||
RecurrenceFreq.Daily,
|
||||
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
|
||||
)
|
||||
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected)
|
||||
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc))
|
||||
.isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `until datetime converts from UTC into the given zone before taking the date`() {
|
||||
// 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there.
|
||||
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin))
|
||||
.isEqualTo(
|
||||
SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `count parses`() {
|
||||
assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5"))
|
||||
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly byday parses as weekday picks`() {
|
||||
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR"))
|
||||
.isEqualTo(
|
||||
SimpleRecurrence(
|
||||
RecurrenceFreq.Weekly,
|
||||
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rules beyond the simple shape are rejected`() {
|
||||
// Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules.
|
||||
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull()
|
||||
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull()
|
||||
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull()
|
||||
assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull()
|
||||
assertThat(parseSimpleRecurrence("")).isNull()
|
||||
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull()
|
||||
assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull()
|
||||
assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toRRule renders the minimal form`() {
|
||||
assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule())
|
||||
.isEqualTo("FREQ=DAILY;INTERVAL=3")
|
||||
assertThat(
|
||||
SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(),
|
||||
).isEqualTo("FREQ=MONTHLY;COUNT=12")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toRRule renders weekdays in ISO order regardless of set order`() {
|
||||
val rule = SimpleRecurrence(
|
||||
RecurrenceFreq.Weekly,
|
||||
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY),
|
||||
).toRRule()
|
||||
assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toRRule ignores weekday picks on non-weekly frequencies`() {
|
||||
val rule = SimpleRecurrence(
|
||||
RecurrenceFreq.Monthly,
|
||||
byDays = setOf(DayOfWeek.MONDAY),
|
||||
).toRRule()
|
||||
assertThat(rule).isEqualTo("FREQ=MONTHLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toRRule writes until as the end of the chosen day in the given zone`() {
|
||||
val rule = SimpleRecurrence(
|
||||
RecurrenceFreq.Weekly,
|
||||
interval = 2,
|
||||
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
|
||||
)
|
||||
assertThat(rule.toRRule(utc))
|
||||
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z")
|
||||
// 23:59:59 Berlin (CEST, +2) == 21:59:59Z.
|
||||
assertThat(rule.toRRule(berlin))
|
||||
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z")
|
||||
}
|
||||
|
||||
// 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence.
|
||||
private val cutoffMillis = 1_781_913_540_000L
|
||||
|
||||
@Test
|
||||
fun `truncation replaces count and keeps every other part`() {
|
||||
assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis))
|
||||
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncation replaces an existing until`() {
|
||||
assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis))
|
||||
.isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncation works on rules the simple picker cannot express`() {
|
||||
assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis))
|
||||
.isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse and render round-trip`() {
|
||||
val rules = listOf(
|
||||
"FREQ=DAILY",
|
||||
"FREQ=WEEKLY;INTERVAL=2",
|
||||
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z",
|
||||
"FREQ=MONTHLY;COUNT=6",
|
||||
"FREQ=YEARLY;UNTIL=20301231T235959Z",
|
||||
)
|
||||
rules.forEach { rule ->
|
||||
assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule)
|
||||
}
|
||||
// Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET.
|
||||
val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z"
|
||||
assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin))
|
||||
.isEqualTo(berlinRule)
|
||||
}
|
||||
}
|
||||
188
docs/superpowers/plans/2026-06-11-03-write-support.md
Normal file
188
docs/superpowers/plans/2026-06-11-03-write-support.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 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 | implementiert (Release wartet auf On-Device-Review) |
|
||||
| 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
|
||||
|
||||
**Domain:**
|
||||
- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln
|
||||
(ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten,
|
||||
solange der Picker sie nicht ersetzt
|
||||
- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches
|
||||
BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit
|
||||
`parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet)
|
||||
- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day-
|
||||
Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)
|
||||
- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße
|
||||
null Vorkommen — Event würde unsichtbar)
|
||||
|
||||
**Data layer:**
|
||||
- [x] `buildEventUpdateValues(original, updated, seriesDtStart, zone)` —
|
||||
Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit:
|
||||
einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend →
|
||||
Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTEND
|
||||
- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row-
|
||||
Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)
|
||||
- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)
|
||||
- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io`
|
||||
- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr —
|
||||
der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
|
||||
|
||||
**UI:**
|
||||
- [x] `EventEditViewModel.openForEdit` (lädt Detail, merkt Original für
|
||||
Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit
|
||||
Werten werden unabhängig vom Settings-Default eingeblendet
|
||||
- [x] `EventEditScreen`: `editKey`-Parameter, Kalender im Edit-Modus fixiert,
|
||||
Repeat-Karte + `RecurrencePickerDialog` (Presets per Tap, Custom-Schritt
|
||||
mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart
|
||||
nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl,
|
||||
OptionCard-Stil)
|
||||
- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular)
|
||||
- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller
|
||||
WRITE-Request wie Delete); Save schließt Formular **und** Detail (die
|
||||
getappte Occurrence existiert danach evtl. nicht mehr)
|
||||
- [x] **Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback):** Die
|
||||
Scope-Frage kommt **beim Speichern** (Google-Modell, Review-Feedback):
|
||||
ein dirty wiederkehrender Termin parkt in `SaveUiState.AwaitingScope`,
|
||||
der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden /
|
||||
Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser"
|
||||
(eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt
|
||||
eine Modified-Occurrence-Exception (`CONTENT_EXCEPTION_URI`, alle
|
||||
Formularwerte, leere Optionals als explizite NULLs weil der Provider
|
||||
die Serien-Row klont), Reminder werden gegen die tatsächlichen
|
||||
Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split:
|
||||
neues Event mit den Formularwerten (insert zuerst — schlägt es fehl,
|
||||
bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt;
|
||||
ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes
|
||||
COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP)
|
||||
- [x] **Delete dreistufig (Review-Feedback):** "Nur dieser Termin" /
|
||||
"Dieser und alle folgenden" (RRULE-Truncation via `rruleTruncatedAt`)
|
||||
/ "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie
|
||||
löschen
|
||||
- [x] **Split-Duplikat-Bugfix (On-Device-Review):** Nach dem Serien-Split
|
||||
blieb die getappte Occurrence doppelt sichtbar. Root cause (per
|
||||
adb-Probe verifiziert): der Provider regeneriert die Instances eines
|
||||
Events nur aus den **Values des Updates selbst** — ein RRULE-only-
|
||||
Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART)
|
||||
erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken
|
||||
deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/
|
||||
EVENT_TIMEZONE) zusammen (`truncateSeries`), wie AOSPs
|
||||
EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff =
|
||||
Ende des Vortags in der Event-Zeitzone (`previousLocalDayEndUtcMillis`)
|
||||
statt Occurrence−1s, und der Recurrence-Picker rendert UNTIL als
|
||||
lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z`
|
||||
(sonst kann bei UTC+x ein Extra-Tag hineinrutschen)
|
||||
- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern
|
||||
- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default)
|
||||
- [x] Strings DE+EN
|
||||
|
||||
**Tests:**
|
||||
- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln)
|
||||
- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung
|
||||
- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only,
|
||||
Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)
|
||||
- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade
|
||||
- [x] `EventDetailMapperTest`: roher Titel
|
||||
|
||||
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
|
||||
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user