20 Commits

Author SHA1 Message Date
35022267dc fix(renovate): run renovate image directly instead of docker-wrapping action
All checks were successful
CI / ci (push) Successful in 1m52s
renovatebot/github-action is a Node wrapper that shells out to
`docker run ghcr.io/renovatebot/renovate`, requiring a Docker CLI + socket
inside the job. The Gitea runner executes the job in a plain node:22 container
with neither, so it died on "Unable to locate executable file: docker".

Run the renovate image as the job container and invoke `renovate` directly —
drops the docker-in-docker requirement. Full tag pinned; Renovate's
github-actions manager keeps container.image bumped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:08:08 +02:00
eeef089e4a fix(renovate): pin action to a real tag (v46.1.15)
All checks were successful
CI / ci (push) Successful in 1m31s
renovatebot/github-action ships only full semver tags; @v40 was an
invalid ref and the dispatched run failed to resolve it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:33:15 +02:00
9023899ddb Merge pull request 'ci(renovate): self-hosted Renovate config + weekly workflow' (#8) from feat/renovate into main
All checks were successful
CI / ci (push) Successful in 8m43s
2026-06-18 15:17:47 +00:00
2f153fef56 ci(renovate): self-hosted Renovate config + weekly workflow
All checks were successful
CI / ci (push) Successful in 1m31s
renovate.json5 (config:recommended + semantic commits, no automerge,
dependency dashboard; material3 stays on its 1.5-alpha pin in an
isolated PR; test deps grouped; github-actions manager watches
.gitea/workflows). Cadence owned by .gitea/workflows/renovate.yml
(Mondays 05:00 UTC + manual dispatch), self-hosted via
renovatebot/github-action, scoped to makiolaj/calendula.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:07:46 +02:00
290a905f8b Merge pull request 'release: v2.7.0 — ICS export & import' (#7) from release/v2.7.0 into main
All checks were successful
Translations / check (push) Successful in 6s
CI / ci (push) Successful in 9m40s
2026-06-18 14:26:53 +00:00
d20d446cbe release: cut v2.7.0 — ICS export & import (.ics share, backup, open/receive)
All checks were successful
CI / ci (push) Successful in 5m48s
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m40s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 5m44s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:24:35 +02:00
6e14d5964b fix(release): keep Room DB impls so R8 doesn't crash startup
The minified release build crashed on every launch before any UI:

  Unable to get provider androidx.startup.InitializationProvider:
    Failed to create an instance of androidx.work.impl.WorkDatabase

The home-screen widgets use Glance, which pulls in WorkManager and its
transitive Room database (room-runtime 2.2.5). Room 2.2.5's bundled keep
rule is `-keep class * extends androidx.room.RoomDatabase` — it keeps the
class but not its constructor. Under R8 full mode (AGP 9) the generated
WorkDatabase_Impl was reduced to a non-instantiable class, so Room's
reflective newInstance() threw InstantiationException at startup.

Add `-keep class * extends androidx.room.RoomDatabase { *; }` so the
generated *_Impl classes keep their constructors. Verified against the
rebuilt release APK: WorkDatabase_Impl is now PUBLIC FINAL with its
<init> present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:15:08 +02:00
3dfc96718c feat(ics): import UI — open/receive .ics, 1-vs-many routing
Completes v2.7 Branch 2. Wires the import core into the app:

- Manifest ACTION_VIEW/SEND for text/calendar; MainActivity parses the
  incoming Uri (content/file only, so calendula:// deep-links don't match)
  and routes it through RootScreen → CalendarHost like the other one-shot
  intents.
- ImportViewModel reads + parses the file and routes by count: one event →
  the prefilled create form for review (EventEditViewModel.openImported,
  which freezes the reminder default so the file's reminders win); many →
  ImportScreen with a writable-calendar picker, then a bulk import (UID
  dedup) and a result summary.
- ImportScreen also surfaces parser warnings (skipped recurrence overrides,
  ignored attendees, unknown-timezone fallback). Strings EN+DE.

Package is ui.imports (not ui.import — Java keyword). lint + test +
assembleDebug green. No v2.7 tag until on-device review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:20:29 +02:00
e1c2e9f2e5 feat(ics): import core — parser, dedup-aware bulk import, form prefill
v2.7 Branch 2 (core, no UI yet). The read side of the .ics engine:

- domain/ics: IcsParser (inverse of IcsWriter) — unfold/unescape/param
  parsing, VALUE=DATE / UTC-Z / TZID date handling resolved against the OS
  tz db, VEVENT walk → ParsedIcsEvent + typed warnings. Liberal-in/
  strict-out: a malformed VEVENT is skipped, RECURRENCE-ID overrides /
  attendees / unresolved TZIDs are reported, not silently dropped.
- Promoted parseRfc2445DurationMillis into domain/ics (shared by writer-
  side mapper and parser); IcsDuration + test.
- Datasource existingUids()/insertImportedEvent(); repository
  importEvents() with UID dedup (skip known UIDs → idempotent restore) →
  IcsImportSummary. IcsImporter reads a Uri's text.
- ParsedIcsEvent.toEventForm() for the single-event "open into the create
  form" path.

Parser round-trips against IcsWriter; dedup + form-adapter unit-tested.
Intent filter, routing and import UI land in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:59:32 +02:00
90b219bdad fix(views): stop single-day all-day events leaking into the next day
All-day events live at UTC midnights with an exclusive end, but coversDay
sliced each day in the device timezone. East of UTC the exclusive end
landed a few hours into the next local day, so a one-day all-day event
(e.g. a birthday) rendered on two days in the day/week/month views — while
the detail and edit screens, which work in UTC, showed it correctly.

Compare all-day coverage in UTC and step the exclusive end back to the
last covered day, mirroring the detail/edit views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:48:34 +02:00
233a9b03a3 Merge feat/ics-export into release/v2.7.0
v2.7 Branch 1 of 2: .ics export — single-event share + whole-calendar backup of local calendars. Import (feat/ics-import) lands next in the same release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:45:59 +02:00
0b683d374f feat(ics): export — share single event + back up local calendars as .ics
Branch 1 of 2 for v2.7 (the .ics topic). Adds the write side of a
hand-rolled RFC 5545 engine (zero deps, stays on kotlinx-datetime):

- domain/ics: IcsText (escape + 75-octet folding), IcsEvent model,
  IcsWriter.writeCalendar. Timezone rule: all-day VALUE=DATE, one-off
  timed UTC Z, recurring timed TZID-labelled from EVENT_TIMEZONE (no
  VTIMEZONE — import resolves TZID against the OS tz db).
- Single-event share from the detail screen (FileProvider + ACTION_SEND).
- Whole-calendar backup of the writable local calendars to a SAF file
  (Settings -> Calendars -> Export as .ics), one combined VCALENDAR.
- insertEvent now writes Events.UID_2445; legacy rows fall back to a
  stable synthesised UID at export time so a later restore won't dupe.
- EXDATE / RECURRENCE-ID overrides are deliberately skipped this pass
  (documented v1 limit; import will skip them too).

Engine + mapper unit-tested. Import (Branch 2, feat/ics-import) ships in
the same v2.7 release; no tag until both land + on-device review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:27:53 +02:00
64d0a89b28 release: cut v2.6.0 — working in-app language picker + system per-app language
All checks were successful
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
CI / ci (push) Successful in 9m33s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 7m2s
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:38:58 +02:00
7285e274df Merge pull request 'feat(i18n): data-driven language picker + Weblate translation guard' (#5) from feat/translations into main
All checks were successful
Translations / check (push) Successful in 4s
CI / ci (push) Successful in 1m43s
2026-06-18 08:41:46 +00:00
788ca3906e Merge remote-tracking branch 'origin/main' into worktree-feat+translations
All checks were successful
Translations / check (push) Successful in 4s
CI / ci (push) Successful in 5m11s
2026-06-18 10:29:00 +02:00
bab6fd175a fix(i18n): make the language picker actually apply on device
The in-app language picker silently did nothing: AppCompatDelegate.set
ApplicationLocales only syncs to the system from an AppCompatActivity, but
MainActivity was a plain ComponentActivity (with a platform theme). Switch
MainActivity to AppCompatActivity and base Theme.Calendula on
Theme.AppCompat.DayNight.NoActionBar.

Changing the locale recreates the activity; set android:windowBackground to a
DayNight colour matching the Compose background (light #FBFCFE / dark #101316)
so the recreation no longer flashes a contrasting backdrop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:28:13 +02:00
3d5cc55ef1 Merge pull request 'feat(reminders): configurable all-day reminder fire time' (#6) from feat/default-reminders into main
All checks were successful
CI / ci (push) Successful in 10m19s
2026-06-18 07:58:47 +00:00
111b3782b0 feat(reminders): configurable all-day reminder fire time
All checks were successful
CI / ci (push) Successful in 3m37s
All-day events live at UTC midnight, so a raw "1 day before" reminder
fires at an off hour (02:00 local in CEST) rather than the morning. Add a
global "all-day reminder time" setting (default 09:00) and encode it into
the provider MINUTES offset so the reminder lands at the chosen wall-clock
time the day before instead.

- AllDayReminderEncoding: pure to/from provider-minutes helpers, keeping
  the form/UI/diff in whole-day "semantic" minutes and converting only at
  the Reminders read/write boundary (insertEvent, reconcileReminders,
  EventDetailMapper). Covers DST, negative offsets, and pre-existing rows.
- SettingsPrefs.allDayReminderTimeMinutes (default 540) threaded from the
  repository into the data-source write paths.
- Settings: a time-picker row, plus a shared TimePickerAlert lifted from
  the event editor.
- Fix the time picker's 12/24-hour detection: honour an explicit system
  override, else fall back to the device locale rather than the app's
  per-app language, so it matches the rest of the device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:54:41 +02:00
cf380b6eab ci(i18n): translation parity guard + allow partial translations
Some checks failed
CI / ci (push) Failing after 17m45s
Translations / check (push) Successful in 7s
Add scripts/check_translations.py and a lightweight Translations workflow
that runs it (no Android SDK needed) so Weblate PRs get fast feedback. The
script fails on stale keys (present in a translation but not the base) and on
translating translatable="false" entries; missing keys are reported as
coverage only.

Downgrade lint's MissingTranslation to informational: partial community
translations are expected and fall back to the English base at runtime.
Stale/extra keys (ExtraTranslation) remain fatal in lintDebug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:43:06 +02:00
9177a926df feat(i18n): data-driven language picker + locale config
Make the supported-language list a single source of truth so community
translations show up with no code change: add res/xml/locales_config.xml
(en, de) and reference it via android:localeConfig, which also surfaces the
per-app language entry in Android 13+ system settings.

Rewrite AppLanguage to parse locales_config.xml for the supported BCP-47
tags and expose currentTag/apply/displayName (autonyms), dropping the
hardcoded LanguagePref enum; the Settings picker is now built from that list.
Remove the now-unused settings_language_german/english strings.

Adding a language is now: drop in values-<tag>/strings.xml and add one
<locale> line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:31:54 +02:00
67 changed files with 4786 additions and 325 deletions

View File

@@ -0,0 +1,42 @@
name: Renovate
on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
workflow_dispatch:
# Never let two Renovate runs touch the repo at once.
concurrency:
group: renovate
cancel-in-progress: false
jobs:
renovate:
runs-on: docker
# Run the Renovate image *as* the job container and invoke the `renovate`
# binary directly. The renovatebot/github-action wrapper is a thin Node
# action that shells out to `docker run …` — it needs a Docker CLI + socket
# inside the job, which the Gitea runner's plain node container has not, so
# it died on "Unable to locate executable file: docker". Running the image
# directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
container:
image: ghcr.io/renovatebot/renovate:43.232.0
steps:
- name: Run Renovate
run: renovate
env:
# Self-hosted Gitea, not github.com.
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Scope to this repo only — no org-wide autodiscovery.
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
# Commits/PRs authored as the bot, not a real maintainer.
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
LOG_LEVEL: info

View File

@@ -0,0 +1,41 @@
name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
on:
push:
branches:
- '**'
tags-ignore:
- '**'
paths:
- 'app/src/main/res/values*/strings.xml'
- 'app/src/main/res/xml/locales_config.xml'
- 'scripts/check_translations.py'
- '.gitea/workflows/translations.yaml'
concurrency:
group: translations-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure python3
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 --version
- name: Check translation parity
run: python3 scripts/check_translations.py

View File

@@ -232,10 +232,26 @@ pass on the existing controls; new toggles ride in with their own features.
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
**Tier 4 — interop & bigger-ticket**
9. Share event as .ics + receive/open .ics into a prefilled create form
10. Default reminder applied to new events; then snooze/dismiss notification actions
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
default reminder **+ per-calendar override**, bundled with battery-exemption
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
four surfaces: single-event share + whole-calendar backup (export),
open-`.ics`→form + whole-calendar restore (import). Closes the
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
sequential branches in one release**: `feat/ics-export` (write side +
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
must re-fire an alarm).
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
@@ -249,8 +265,9 @@ pass on the existing controls; new toggles ride in with their own features.
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
whether drag-drop (#11) jumps ahead given its daily-driver impact.
Debatable calls worth a second look: whether **local-calendar backup (#10)**
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
whether drag-drop (#12) jumps ahead given its daily-driver impact.
## Navigation & views
@@ -260,9 +277,14 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
- Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1)
- Current-time "now" line in day/week — standard in every calendar, cheap,
currently absent. Daily-driver polish.
- Week numbers in the **month** grid — week view already shows the badge
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
- Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)*
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
with real event history, finding an event is core completeness, not optional.
## Event editing & creation
@@ -297,12 +319,103 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
go/no-go gate as the OSM/INTERNET item below.
- Move event to another calendar (copy+delete model with a consequences
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
- **Local-calendar backup / export** *(Tier 4 #10)* — device-only
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
restore-on-import that recreates events into a chosen local calendar. Reuses
the .ics serializer from the single-event share work; the restore path reuses
the import parser. A data-integrity obligation, not a feature.
## Reminders, round two
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
Two themes bundled because both are "make reminders trustworthy" — the core of
the "Calendula is your only calendar app" promise.
**Built in this slice (A + the safe half of B):** global timed default reminder
+ a **separate all-day default** (day-scale lead times) + per-calendar override
(timed events), applied on create with manual-edit / calendar-switch / all-day-
toggle handling; three pickers + per-calendar override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link, no
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
Resolution model: all-day events use the all-day global default outright;
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
applied: form-reset state race, label-fn consolidation with the detail screen,
inline wrapper + single combined flow read.
**Deliberately deferred (documented decisions, not oversights):**
- *Absolute time-of-day for all-day reminders* — the all-day default is still
minutes-before-midnight (day-scale presets), not "9am the day before" (open
decision #2's richer half). Per-calendar all-day overrides also deferred.
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
(open decision #1). The battery exemption is the reliability lever; no
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
exemption lives only in Settings for now (onboarding flow untouched to keep
the change reviewable).
### A. Default reminders (global + per-calendar override)
**No provider backing.** `CalendarContract` has no column that auto-applies a
default reminder per calendar — Google's per-calendar defaults live server-side.
So both the global default *and* the per-calendar override are **app-side
preferences**, applied by us at event-insert time. We inherit nothing from the
synced calendar.
- **Storage (DataStore):**
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
reminders are expressed as minutes before midnight / day-before-at-time, not
minutes before a start instant — they need their own value).
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
**absent key = inherit global**, explicit `null` = "no reminder for this
calendar". (Same for an all-day override map if we want per-calendar all-day.)
- **Apply on create:** a fresh event prefills its reminders list from
override-or-global for the preselected calendar. Changing the calendar in the
form re-applies the *new* calendar's default **only if the user hasn't manually
edited the reminders** — track a dirty flag, mirroring the per-event-color
reset pattern (v2.4).
- **Edit semantics:** defaults apply to **new events only**; never rewrite
reminders on existing events on open or on calendar-switch-during-edit.
- **Settings UI (Notifications sub-page):**
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
/ 1 h / 1 day / custom), plus the separate all-day default.
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
or a Notifications subsection), each opening the same OptionCard with a
leading **"Use global default"** option.
### B. Delivery reliability (exact alarms + battery)
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
it directly undermines the feature's premise, so it rides in here.
- **Exact alarm — decision first:** trust the provider broadcast, or
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
fallback for API 3132 (user-revocable → settings deep-link prompt).
- **Battery-optimization exemption:** a *soft, optional* prompt via
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
auto-grant intent), honest copy: "Android may delay reminders to save battery;
exempt Calendula for on-time delivery." Shown once after the existing
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
are notorious for suppressing it).
### Open decisions (resolve before building)
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
(reliability vs simplicity + battery cost).
2. All-day reminder representation (minutes-before vs absolute time-of-day).
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
a list inside the Notifications sub-page).
### Later (round two)
- Snooze + dismiss actions on the notification (snooze needs an
exact-alarm / WorkManager decision)
- Settings default reminder applied to new events
exact-alarm / WorkManager decision) — Tier 4 #13.
## Sharing & interop
@@ -312,8 +425,18 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
## Platform & launchers
- Home-screen widget *(was v3.0)*
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
optional quick-settings tile still open
## Quality & reliability
- **Accessibility pass** — TalkBack content descriptions across all screens,
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
F-Droid app; nothing tracks it yet.
- **Reminder delivery reliability** — exact alarms + battery-optimization
exemption; specced in the "Reminders — defaults & delivery reliability" slice
above (Tier 4 #9).
## Locations & People *(go/no-go, captured 2026-06-11)*

View File

@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.7.0] — 2026-06-18
### Added
- Share a single event as an `.ics` file from the event detail screen — hands a
standard calendar file to any app via the system share sheet.
- Back up your local (device-only) calendars: Settings → Calendars → Export as
`.ics` file writes every event of your on-device calendars to a file you
choose. Local calendars aren't synced anywhere, so this is their only backup.
- Open or share an `.ics` file into Calendula: a single event opens the create
form prefilled for review, while a file with many events (e.g. a backup) opens
a bulk import — pick a calendar and import them all. Re-importing a backup
won't create duplicates (events are matched by their unique identifier), and
anything Calendula can't represent (changed recurring occurrences, guest
lists) is reported rather than silently dropped.
### Fixed
- All-day events that cover a single day (e.g. a birthday) no longer show up on
the following day as well — in the day, week and month views or on the event
detail screen. The extra day came from interpreting the all-day date range in
the device's time zone instead of UTC.
- Fixed the app crashing immediately on every launch in the optimized release
build: release code-shrinking (R8) was stripping a database class the
home-screen widget framework needs, so the app died at startup before showing
anything. Added the missing keep rule.
## [2.6.0] — 2026-06-18
### Added
- App language can now be set from Android's system per-app language settings
(Android 13+), in addition to the in-app picker in Settings — and the app is
set up so further languages can be added by community translators
### Fixed
- Changing the app language in Settings now takes effect immediately; the
picker previously had no effect
## [2.5.0] — 2026-06-17
### Added

View File

@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20500
versionName = "2.5.0"
versionCode = 20700
versionName = "2.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -78,6 +78,15 @@ android {
}
}
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
informational += "MissingTranslation"
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }

View File

@@ -4,3 +4,14 @@
# Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.**
# Room database implementations (pulled in transitively via
# androidx.glance:glance-appwidget androidx.work androidx.room).
# The widgets rely on Glance, whose WorkManager backend stores state in a Room
# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
# so Room's reflective instantiation throws InstantiationException and the app
# crashes at startup with "Failed to create an instance of ...WorkDatabase".
# Keep the generated Room database implementations fully intact.
-keep class * extends androidx.room.RoomDatabase { *; }
-dontwarn androidx.room.paging.**

View File

@@ -5,6 +5,13 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Lets the "Reliable delivery" setting open the direct system dialog to
exempt Calendula from battery optimisation (so reminder broadcasts aren't
delayed by Doze). Used only to launch that dialog; falls back to the
battery-optimisation list if the OS declines the direct intent.
-->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
@@ -25,6 +32,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Calendula"
@@ -39,6 +47,21 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:mimeType="text/calendar" />
<data android:scheme="file" android:mimeType="text/calendar" />
</intent-filter>
<!-- Receive a .ics shared from another app. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data
android:name="android.app.shortcuts"
@@ -104,6 +127,19 @@
</intent-filter>
</receiver>
<!-- Hands .ics files we stage in the cache to other apps via a content
Uri (single-event share). Authority tracks applicationId so the
debug suffix doesn't break getUriForFile. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Persists the per-app language (M4) on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service

View File

@@ -2,16 +2,18 @@ package de.jeanlucmakiola.calendula
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -24,7 +26,7 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
// The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a
@@ -35,11 +37,16 @@ class MainActivity : ComponentActivity() {
// create). Consumed once by CalendarHost, same pattern as the detail key.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -60,6 +67,8 @@ class MainActivity : ComponentActivity() {
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
)
}
}
@@ -69,6 +78,21 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
}
/**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
* app's own `calendula://` deep-links never match.
*/
private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) {
Intent.ACTION_VIEW -> data
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
else -> null
} ?: return null
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {

View File

@@ -0,0 +1,70 @@
package de.jeanlucmakiola.calendula.data.calendar
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Translates an all-day reminder between the **semantic** lead time the UI
* speaks (whole days before the event — "1 day before") and the **raw**
* `CalendarContract.Reminders.MINUTES` offset the provider stores.
*
* Calendula schedules no alarms itself: the provider fires a reminder at
* `DTSTART MINUTES` (the Etar model). An all-day event's DTSTART is **UTC
* midnight** (see [EventWriteTimes]), so a raw `MINUTES = 1440` ("1 day") lands
* on UTC-midnight of the previous day — 02:00 local in CEST, not the morning.
*
* To fire at a chosen wall-clock time we encode that time *into* the offset:
* `MINUTES = UTC-midnight(startDate) (localInstant of [timeOfDayMinutes] on the
* day [semanticMinutes] before)`. The single fixed offset can only be tuned for
* the event's own date, so a recurring all-day series or a post-creation
* timezone change drifts the fire time by the offset delta (±1h across DST) —
* an inherent limit of the provider model, shared by Etar.
*/
private const val MINUTES_PER_DAY = 1_440
private const val MILLIS_PER_MINUTE = 60_000L
/**
* Raw provider `MINUTES` for an all-day reminder set [semanticMinutes] before the
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to 420, meaning
* the provider fires *after* DTSTART; this is valid and must not be clamped.
* A negative [semanticMinutes] is the "provider default" sentinel and passes
* through unchanged.
*/
internal fun toProviderAllDayMinutes(
semanticMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
timeOfDayMinutes: Int,
): Int {
if (semanticMinutes < 0) return semanticMinutes
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli()
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
}
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
* and rows written under a different timezone. A negative [rawMinutes] (fire
* after DTSTART) folds to day 0.
*/
internal fun fromProviderAllDayMinutes(
rawMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
): Int {
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
.atZone(zone).toLocalDate()
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
}

View File

@@ -18,10 +18,15 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -47,6 +52,26 @@ interface CalendarDataSource {
*/
fun eventColorPalette(calendarId: Long): List<EventColorOption>
/**
* Every master/one-off event of the writable local calendars, mapped for a
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
* rows are excluded (see [EventExportProjection]).
*/
fun exportableEvents(): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
* dedup an `.ics` import so re-importing a backup doesn't double events.
*/
fun existingUids(calendarId: Long): Set<String>
/**
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
* minting one when absent); returns the new `Events._ID`. Reminders are
* written as the file's raw lead minutes (METHOD_ALERT).
*/
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
@@ -60,24 +85,40 @@ interface CalendarDataSource {
/** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long)
/** Insert a new event; returns the new `Events._ID`. */
fun insertEvent(form: EventForm): Long
/**
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
* (minutes from local midnight) is the wall-clock time all-day reminders
* should fire at — encoded into each all-day reminder's provider offset
* (ignored for timed events).
*/
fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long
/**
* Update an existing event (for recurring events: the whole series) to
* 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.
* [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
)
/**
* Change a single occurrence of a recurring event by inserting a
* modified-occurrence exception at [beginMillis] (the occurrence's
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
* row's `Events._ID`.
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/**
* Change a recurring event from the occurrence at [beginMillis] onwards
@@ -92,6 +133,7 @@ interface CalendarDataSource {
beginMillis: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/**
@@ -247,6 +289,112 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Map id → display name for the
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
val names = calendars()
.filter { it.isLocal && it.canModifyContents }
.associate { it.id to it.displayName }
if (names.isEmpty()) return emptyList()
val idList = names.keys.joinToString(",")
return resolver.query(
CalendarContract.Events.CONTENT_URI,
EventExportProjection.COLUMNS,
// Skip soft-deleted rows and exception rows (modified occurrences /
// cancellations) — v1 exports masters + one-offs only.
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
null,
CalendarContract.Events.DTSTART + " ASC",
)?.use { c ->
c.mapAll {
val reader = CursorColumnReader(c)
val eventId = reader.getLong(EventExportProjection.IDX_ID)
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
)
}
} ?: emptyList()
}
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events.UID_2445),
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL",
arrayOf(calendarId.toString()),
null,
)?.use { c ->
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
} ?: emptySet()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
val startMillis = event.start.toEpochMillis()
val endMillis = event.end.toEpochMillis()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
// Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none.
put(
CalendarContract.Events.UID_2445,
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
)
put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
)
}
// All-day rows live at UTC midnights (the file already encodes them so);
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("import event into calendar id=$calendarId")
val eventId = ContentUris.parseId(uri)
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
}
return eventId
}
/** Provider DURATION for an imported recurring row: whole days / seconds. */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
@@ -265,13 +413,44 @@ class AndroidCalendarDataSource @Inject constructor(
private data class CalendarAccount(val name: String, val type: String)
override fun insertEvent(form: EventForm): Long {
/**
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
* all-day reminder is shifted to fire at [allDayReminderTimeMinutes] local
* (see [toProviderAllDayMinutes]); a timed reminder is its lead time as-is.
*/
private fun providerReminderMinutes(
form: EventForm,
minutes: Int,
allDayReminderTimeMinutes: Int,
): Int = if (form.isAllDay) {
toProviderAllDayMinutes(
semanticMinutes = minutes,
startDate = form.start.date.toJavaLocalDate(),
zone = ZoneId.systemDefault(),
timeOfDayMinutes = allDayReminderTimeMinutes,
)
} else {
minutes
}
/** [form]'s reminders as the distinct raw provider offsets to store. */
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
form.reminders
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
.distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
// A globally-unique UID so a later .ics backup/restore can identify
// the event and not duplicate it on re-import (the provider leaves
// this null for events it didn't sync). Older rows without one fall
// back to a stable synthesised UID at export time (deriveIcsUid).
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
@@ -303,20 +482,26 @@ class AndroidCalendarDataSource @Inject constructor(
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)
encodedReminders(form, allDayReminderTimeMinutes)
.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")
}
}
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) {
override fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
) {
val values = buildEventUpdateValues(
original = original,
updated = updated,
@@ -332,13 +517,19 @@ class AndroidCalendarDataSource @Inject constructor(
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.
// disturb provider rows the form never knew about. The diff is on the
// form's semantic minutes; reconcile works in encoded provider minutes.
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, updated.reminders)
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues(
form = form,
@@ -352,7 +543,7 @@ class AndroidCalendarDataSource @Inject constructor(
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)
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
return exceptionId
}
@@ -361,16 +552,17 @@ class AndroidCalendarDataSource @Inject constructor(
beginMillis: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): 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)
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
return eventId
}
// Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated)
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
truncateSeries(eventId, row, beginMillis)
return newEventId
}
@@ -456,9 +648,11 @@ class AndroidCalendarDataSource @Inject constructor(
}
/**
* 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.
* Make the event's reminder rows match [targetMinutes] — the raw provider
* offsets to store (already encoded via [encodedReminders], so all-day shifts
* are baked in and the diff matches the stored rows). Rows with other offsets
* are deleted, missing ones inserted as best-effort ALERTs (like insertEvent).
* Rows whose minutes survive keep their method.
*/
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
val target = targetMinutes.toSet()

View File

@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
@@ -28,6 +31,19 @@ interface CalendarRepository {
/** Permanently delete a local calendar the app owns, with all its events. */
suspend fun deleteCalendar(id: Long)
/**
* Every event of the writable local calendars, ready to serialise into a
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
*/
suspend fun exportEvents(): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
* UID already exists in the target are skipped (idempotent restore); the
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
*/
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
/** Create a new event from a validated form; returns the new `Events._ID`. */
suspend fun createEvent(form: EventForm): Long

View File

@@ -2,15 +2,19 @@ package de.jeanlucmakiola.calendula.data.calendar
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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
@@ -28,9 +32,14 @@ import javax.inject.Singleton
class CalendarRepositoryImpl @Inject constructor(
private val dataSource: CalendarDataSource,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : CalendarRepository {
/** The configured wall-clock fire time for all-day reminders, read per write. */
private suspend fun allDayReminderTimeMinutes(): Int =
settingsPrefs.allDayReminderTimeMinutes.first()
private val ticks = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
@@ -92,8 +101,30 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
): IcsImportSummary = withContext(io) {
val existing = dataSource.existingUids(targetCalendarId)
var imported = 0
var skipped = 0
for (event in events) {
// A known UID means the event is already in this calendar — skip,
// keeping a restore idempotent (no overwrite this pass).
if (event.uid != null && event.uid in existing) {
skipped++
} else {
dataSource.insertImportedEvent(event, targetCalendarId)
imported++
}
}
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
dataSource.insertEvent(form)
dataSource.insertEvent(form, allDayReminderTimeMinutes())
}
override suspend fun updateEvent(
@@ -101,7 +132,7 @@ class CalendarRepositoryImpl @Inject constructor(
original: EventForm,
updated: EventForm,
) = withContext(io) {
dataSource.updateEvent(eventId, original, updated)
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
}
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
@@ -113,7 +144,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
form: EventForm,
): Long = withContext(io) {
dataSource.updateOccurrence(eventId, beginMillis, form)
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
}
override suspend fun updateEventFromOccurrence(
@@ -122,7 +153,9 @@ class CalendarRepositoryImpl @Inject constructor(
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
)
}
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {

View File

@@ -13,6 +13,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper"
@@ -58,6 +61,7 @@ internal fun ColumnReader.toEventDetailCore(
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
val instance = EventInstance(
instanceId = eventId,
eventId = eventId,
@@ -65,11 +69,23 @@ internal fun ColumnReader.toEventDetailCore(
title = title,
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
isAllDay = isAllDay,
color = color,
location = getString(EventDetailProjection.IDX_LOCATION),
)
// All-day reminders are stored as a wall-clock-shifted offset (see
// AllDayReminderEncoding); decode back to the whole-day lead time the form
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date.
val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
val zone = ZoneId.systemDefault()
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
} else {
reminders
}
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
@@ -84,7 +100,7 @@ internal fun ColumnReader.toEventDetailCore(
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = reminders,
reminders = displayReminders,
status = status,
// BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed.

View File

@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
* for backup. [reminderMinutes] are the row's raw provider reminder offsets and
* [calendarName] the display name of its calendar (emitted as
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
* MapColumnReader.
*/
internal fun ColumnReader.toIcsEvent(
reminderMinutes: List<Int>,
calendarName: String?,
): IcsEvent {
val eventId = getLong(EventExportProjection.IDX_ID)
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event.
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
recurrenceRule = rrule,
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = reminderMinutes,
status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
)
}

View File

@@ -97,6 +97,48 @@ internal object EventDetailProjection {
const val IDX_EVENT_COLOR_KEY = 17
}
/**
* Master/one-off Events rows for a whole-calendar backup. Unlike
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
* occurrence and cancelled-exception rows are filtered out by the query
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
* exported yet — a documented v1 limit (import skips them too).
*/
internal object EventExportProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.RRULE,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.STATUS,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.CALENDAR_ID,
)
const val IDX_ID = 0
const val IDX_UID = 1
const val IDX_TITLE = 2
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_TIMEZONE = 7
const val IDX_RRULE = 8
const val IDX_LOCATION = 9
const val IDX_DESCRIPTION = 10
const val IDX_STATUS = 11
const val IDX_AVAILABILITY = 12
const val IDX_CALENDAR_ID = 13
}
internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,

View File

@@ -0,0 +1,45 @@
package de.jeanlucmakiola.calendula.data.ics
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* The Android IO edge of `.ics` export: writes a serialised calendar to a
* SAF document (whole-calendar backup) or stages it in a cache file behind a
* `FileProvider` content Uri (single-event share). The serialisation itself is
* the pure `domain.ics.IcsWriter`; this class only moves bytes.
*/
@Singleton
class IcsExporter @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */
fun writeDocument(uri: Uri, content: String) {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(content.toByteArray(Charsets.UTF_8))
} ?: throw IOException("Could not open $uri for writing")
}
/**
* Stage [content] in a private cache file and return a shareable content
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
* the receiving app. The authority mirrors the manifest's `FileProvider`.
*/
fun stageShareFile(fileName: String, content: String): Uri {
val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() }
val file = File(dir, fileName)
file.writeText(content, Charsets.UTF_8)
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
private companion object {
const val SHARE_DIR = "shared_ics"
}
}

View File

@@ -0,0 +1,21 @@
package de.jeanlucmakiola.calendula.data.ics
import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Android IO edge of `.ics` import: reads the text of a received/opened
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
* pulls bytes off the ContentResolver. Returns null on any read failure.
*/
@Singleton
class IcsImporter @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun readText(uri: Uri): String? = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
}.getOrNull()
}

View File

@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.Flow
@@ -127,6 +128,97 @@ class SettingsPrefs @Inject constructor(
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
}
/**
* The default reminder lead time (minutes before start) prefilled on new
* **timed** events. `null` = no default reminder — the prior behaviour, kept
* as the factory default so existing users aren't surprised by reminders they
* never asked for. Stored as a string so "none" is distinct from a numeric
* value (and from an unset key, which is also "none"). Per-calendar overrides
* in [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
}
suspend fun setDefaultReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
}
/**
* The default reminder lead time prefilled on new **all-day** events, in
* minutes before the start of the day. All-day events want day-scale lead
* times ("1 day before"), so they have their own default rather than reusing
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
* to all-day events — they always use this global value.
*/
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
}
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
}
/**
* Wall-clock time, as minutes from local midnight, at which **all-day**
* reminders fire. All-day events live at UTC midnight, so a raw "1 day
* before" would fire at an off hour (02:00 local in CEST); this time is
* encoded into the provider offset so the reminder lands at, e.g., 09:00 the
* day before instead. Global for every all-day reminder; default 09:00.
* Stored/clamped to a valid 0..1439 minute-of-day.
*/
val allDayReminderTimeMinutes: Flow<Int> = store.data.map { prefs ->
(prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME)
.coerceIn(0, MINUTES_PER_DAY - 1)
}
suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
}
/**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global
* timed default for its new events: a `null` value means "no reminder", an int
* means that lead time. A calendar **absent** from the map inherits the global
* default. Serialised as `id=value;id=value`, with `none` for an explicit
* no-reminder override. (All-day events ignore this and use
* [defaultAllDayReminderMinutes].)
*/
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
}
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
store.edit { prefs ->
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyOverride(calendarId, override)
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
}
}
/**
* Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day**
* events, with the same semantics as [perCalendarReminderOverride] (absent =
* inherit the global all-day default; present null = no reminder).
*/
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
}
suspend fun setCalendarAllDayReminderOverride(
calendarId: Long,
override: CalendarReminderOverride,
) {
store.edit { prefs ->
val current =
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyOverride(calendarId, override)
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
}
}
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
@@ -143,10 +235,90 @@ class SettingsPrefs @Inject constructor(
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
booleanPreferencesKey("allow_color_unsupported_calendars")
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
internal val DEFAULT_ALLDAY_REMINDER_KEY =
stringPreferencesKey("default_allday_reminder_minutes")
internal val ALLDAY_REMINDER_TIME_KEY =
intPreferencesKey("allday_reminder_time_minutes")
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
}
}
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
sealed interface CalendarReminderOverride {
/** No override — the calendar uses the global default. */
data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */
data class Minutes(val minutes: Int) : CalendarReminderOverride
}
/**
* The lead time to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
* it can be unit-tested.
*/
fun resolveDefaultReminder(
timedGlobal: Int?,
allDayGlobal: Int?,
timedOverrides: Map<Long, Int?>,
allDayOverrides: Map<Long, Int?>,
calendarId: Long?,
isAllDay: Boolean,
): Int? {
val overrides = if (isAllDay) allDayOverrides else timedOverrides
val global = if (isAllDay) allDayGlobal else timedGlobal
return if (calendarId != null && overrides.containsKey(calendarId)) {
overrides[calendarId]
} else {
global
}
}
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
private fun MutableMap<Long, Int?>.applyOverride(
calendarId: Long,
override: CalendarReminderOverride,
) {
when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, null)
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
}
}
private const val NONE = "none"
private const val ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "="
private fun String?.toReminderMinutes(): Int? = when (this) {
null, "", NONE -> null
else -> toIntOrNull()
}
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
}.toMap()
}
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventDetail
/**
* Build the [IcsEvent] for sharing a single event. We export the event the user
* is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one
* occurrence, so "share this event" should hand off exactly that instance, not
* a whole series anchored to a possibly-different DTSTART. Reminders are the
* already-decoded semantic lead times the detail screen holds.
*/
fun EventDetail.toShareIcsEvent(): IcsEvent {
val startMillis = instance.start.toEpochMilliseconds()
return IcsEvent(
uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis),
summary = instance.title,
start = instance.start,
end = instance.end,
isAllDay = instance.isAllDay,
zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC",
recurrenceRule = null,
location = instance.location,
description = description,
reminderMinutes = reminders.map { it.minutes },
status = status,
availability = availability,
calendarName = null,
)
}

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.domain.ics
// Android's calendar provider (and Calendula's own writes) use the non-standard
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
// leading T. Matched first; anything else falls through to the general grammar.
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
private val DURATION_GENERAL =
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
/**
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `-PT15M`, `P1W`, …),
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
*/
fun parseRfc2445DurationMillis(duration: String?): Long {
if (duration.isNullOrBlank()) return 0L
val s = duration.trim()
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
val unitSeconds = when (m.groupValues[3]) {
"W" -> 7L * 24 * 60 * 60
"D" -> 24L * 60 * 60
else -> 1L // S
}
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
}
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
val days = m.groupValues[3].toLongOrNull() ?: 0L
val hours = m.groupValues[4].toLongOrNull() ?: 0L
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
return m.signum() * totalSeconds * 1_000L
}
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L

View File

@@ -0,0 +1,43 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlin.time.Instant
/**
* A single event ready to be serialised to a `VEVENT`, decoupled from the
* provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are
* absolute instants; [isAllDay] and [recurrenceRule] decide how they are
* rendered (see [IcsWriter]'s timezone rule).
*/
data class IcsEvent(
/** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */
val uid: String,
val summary: String,
val start: Instant,
/** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */
val end: Instant,
val isAllDay: Boolean,
/** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */
val zoneId: String,
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
val recurrenceRule: String? = null,
val location: String? = null,
val description: String? = null,
/** Reminder lead times in minutes before start (raw provider offsets). */
val reminderMinutes: List<Int> = emptyList(),
val status: EventStatus = EventStatus.Confirmed,
val availability: Availability = Availability.Busy,
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
val calendarName: String? = null,
)
/**
* The UID to export for a provider event. A row that already carries a UID
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
* the event id and its DTSTART so the same legacy event yields the same UID
* across repeated backups — which keeps a later restore from duplicating it.
*/
fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String =
existingUid?.trim()?.takeIf { it.isNotEmpty() }
?: "$eventId-$dtStartMillis@calendula"

View File

@@ -0,0 +1,259 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/**
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
* but [uid] is nullable (an incoming event may carry none; the insert layer
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
* how the writer encoded them.
*/
data class ParsedIcsEvent(
val uid: String?,
val summary: String,
val start: Instant,
val end: Instant,
val isAllDay: Boolean,
val zoneId: String,
val recurrenceRule: String? = null,
val location: String? = null,
val description: String? = null,
val reminderMinutes: List<Int> = emptyList(),
val status: EventStatus = EventStatus.Confirmed,
val availability: Availability = Availability.Busy,
val calendarName: String? = null,
)
/** Things the parser dropped rather than failing — surfaced in the import report. */
enum class IcsParseWarning {
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
ModifiedOccurrenceSkipped,
/** A `VEVENT` with no parseable `DTSTART`. */
EventWithoutStartSkipped,
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
AttendeesIgnored,
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
UnknownTimezone,
}
data class IcsParseResult(
val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>,
)
/** Outcome of a bulk `.ics` import into one calendar. */
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
/**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
* resolved against the OS tz database instead ([deviceZone] is the fallback).
*/
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
fun parse(text: String): IcsParseResult {
val lines = unfoldLines(text)
val events = mutableListOf<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>()
var calendarName: String? = null
var i = 0
while (i < lines.size) {
val line = parseContentLine(lines[i])
if (line == null) { i++; continue }
when {
line.isBegin("VEVENT") -> {
val end = indexOfEnd(lines, i + 1, "VEVENT")
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
?.let(events::add)
i = end + 1
}
line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database.
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
}
line.name == "X-WR-CALNAME" -> {
calendarName = unescapeText(line.value).trim().ifEmpty { null }
i++
}
else -> i++
}
}
return IcsParseResult(events, warnings)
}
private fun parseVevent(
body: List<String>,
fileCalendarName: String?,
warnings: MutableSet<IcsParseWarning>,
): ParsedIcsEvent? {
var uid: String? = null
var summary = ""
var dtStart: IcsDateTime? = null
var dtEnd: IcsDateTime? = null
var duration: String? = null
var rrule: String? = null
var location: String? = null
var description: String? = null
var status = EventStatus.Confirmed
var availability = Availability.Busy
var calendarName = fileCalendarName
val reminders = mutableListOf<Int>()
var skipAsOverride = false
var i = 0
while (i < body.size) {
val line = parseContentLine(body[i])
if (line == null) { i++; continue }
when (line.name) {
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
val end = indexOfEnd(body, i + 1, "VALARM")
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
i = end + 1
continue
}
"UID" -> uid = line.value.trim().ifEmpty { null }
"SUMMARY" -> summary = unescapeText(line.value)
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
"DURATION" -> duration = line.value.trim()
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
"STATUS" -> status = mapIcsStatus(line.value)
"TRANSP" -> availability =
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
else Availability.Busy
"RECURRENCE-ID" -> skipAsOverride = true
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
"X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
}
i++
}
if (skipAsOverride) {
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
return null
}
val start = dtStart ?: run {
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null
}
val end = dtEnd
?: duration?.let {
start.copy(
instant = Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
),
)
}
?: start
return ParsedIcsEvent(
uid = uid,
summary = summary,
start = start.instant,
end = end.instant,
isAllDay = start.isAllDay,
zoneId = start.zoneId,
recurrenceRule = rrule,
location = location,
description = description,
reminderMinutes = reminders.distinct(),
status = status,
availability = availability,
calendarName = calendarName,
)
}
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
private fun parseAlarmMinutes(body: List<String>): Int? {
val trigger = body.asSequence()
.mapNotNull { parseContentLine(it) }
.firstOrNull { it.name == "TRIGGER" }
?: return null
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
val millis = parseRfc2445DurationMillis(trigger.value)
// Negative = before start (the normal case) → positive lead minutes.
return (-millis / 60_000L).toInt().coerceAtLeast(0)
}
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
val raw = line.value.trim()
val isDate = line.params["VALUE"].equals("DATE", true) ||
(raw.length == 8 && !raw.contains('T'))
if (isDate) {
val date = parseBasicDate(raw) ?: return null
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
}
val isUtc = raw.endsWith("Z")
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
}
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
private companion object {
fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true)
/** Index of the matching `END:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from
while (i < lines.size) {
val line = parseContentLine(lines[i])
if (line != null && line.name == "END" &&
line.value.trim().equals(component, true)
) {
return i
}
i++
}
return lines.size
}
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
"TENTATIVE" -> EventStatus.Tentative
"CANCELLED" -> EventStatus.Cancelled
else -> EventStatus.Confirmed
}
fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = LocalDate(
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
)
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
}
}

View File

@@ -0,0 +1,155 @@
package de.jeanlucmakiola.calendula.domain.ics
/**
* Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the
* escaping and folding rules can be tested in isolation. Pure Kotlin — no
* Android, no time handling.
*/
/** iCalendar mandates CRLF line breaks, not the platform separator. */
const val ICS_CRLF: String = "\r\n"
/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */
private const val MAX_OCTETS = 75
/**
* Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11:
* backslash, semicolon and comma are escaped, newlines become the literal `\n`.
* Backslash is handled first so it doesn't double-escape the others' markers.
*/
fun escapeText(value: String): String = buildString(value.length) {
for (ch in value) {
when (ch) {
'\\' -> append("\\\\")
';' -> append("\\;")
',' -> append("\\,")
'\n' -> append("\\n")
'\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n
else -> append(ch)
}
}
}
/**
* Fold a single content line to ≤75 octets per physical line, inserting
* `CRLF + space` between segments (the space is part of the 75-octet budget of
* the continuation line, so its content caps at 74). Folding counts UTF-8
* octets, never splitting a multi-byte character across a boundary.
*/
fun foldLine(line: String): String {
if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line
val out = StringBuilder()
var octetsThisLine = 0
var first = true
var i = 0
while (i < line.length) {
val cp = line.codePointAt(i)
val width = Character.charCount(cp)
val piece = line.substring(i, i + width)
val pieceOctets = piece.toByteArray(Charsets.UTF_8).size
// Continuation lines spend one octet on the leading space.
val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1
if (octetsThisLine + pieceOctets > budget) {
out.append(ICS_CRLF).append(' ')
octetsThisLine = 0
first = false
}
out.append(piece)
octetsThisLine += pieceOctets
i += width
}
return out.toString()
}
/**
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
* `\,`. A backslash before any other character is dropped, keeping the
* character (lenient — foreign files escape liberally).
*/
fun unescapeText(value: String): String = buildString(value.length) {
var i = 0
while (i < value.length) {
val c = value[i]
if (c == '\\' && i + 1 < value.length) {
when (val next = value[i + 1]) {
'n', 'N' -> append('\n')
else -> append(next) // \\, \;, \, and any other escaped char
}
i += 2
} else {
append(c)
i++
}
}
}
/**
* Reverse of [foldLine] across a whole document: split into physical lines on
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
*/
fun unfoldLines(text: String): List<String> {
val out = mutableListOf<String>()
for (physical in text.split("\r\n", "\n", "\r")) {
if (physical.isEmpty()) continue
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
if (isContinuation && out.isNotEmpty()) {
out[out.lastIndex] = out.last() + physical.substring(1)
} else {
out.add(physical)
}
}
return out
}
/**
* One unfolded content line split into its property name, parameters and value:
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
* `Lunch`. The value is everything after the first colon that isn't inside a
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
* Returns null for a line with no colon.
*/
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
fun parseContentLine(line: String): IcsContentLine? {
var inQuote = false
var colon = -1
for (i in line.indices) {
when (line[i]) {
'"' -> inQuote = !inQuote
':' -> if (!inQuote) { colon = i; break }
}
}
if (colon < 0) return null
val head = splitUnquoted(line.substring(0, colon), ';')
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
if (name.isEmpty()) return null
val params = buildMap {
for (part in head.drop(1)) {
val eq = part.indexOf('=')
if (eq > 0) {
put(
part.substring(0, eq).trim().uppercase(),
part.substring(eq + 1).trim().removeSurrounding("\""),
)
}
}
}
return IcsContentLine(name, params, line.substring(colon + 1))
}
/** Split on [delimiter] except where it falls inside a double-quoted run. */
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
val parts = mutableListOf<String>()
val current = StringBuilder()
var inQuote = false
for (c in text) {
when {
c == '"' -> { inQuote = !inQuote; current.append(c) }
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
else -> current.append(c)
}
}
parts.add(current.toString())
return parts
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** Default `PRODID` advertising the writer that produced the file. */
const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
/**
* Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal
* library: we stay on `kotlinx-datetime` and own the output, exactly as
* `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller
* supplies [dtStamp] (the export moment) so the writer never reads a clock.
*
* Timezone rule (see plan 05, decision 1):
* - all-day → `VALUE=DATE`, no zone;
* - timed one-off → UTC instant with a `Z` suffix (an instant is an instant);
* - timed recurring → `TZID`-labelled local wall time, so the series stays
* anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import
* resolves the `TZID` against the OS tz database.
*/
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
val lines = buildList {
add("BEGIN:VCALENDAR")
add("VERSION:2.0")
add("PRODID:$prodId")
add("CALSCALE:GREGORIAN")
events.forEach { appendEvent(it, dtStamp) }
add("END:VCALENDAR")
}
return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) }
}
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
add("BEGIN:VEVENT")
add("UID:${event.uid}")
add("DTSTAMP:${utcStamp(dtStamp)}")
add("SUMMARY:${escapeText(event.summary)}")
appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() }
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
event.location?.takeIf { it.isNotBlank() }
?.let { add("LOCATION:${escapeText(it)}") }
event.description?.takeIf { it.isNotBlank() }
?.let { add("DESCRIPTION:${escapeText(it)}") }
add("STATUS:${statusValue(event.status)}")
add("TRANSP:${transpValue(event.availability)}")
event.calendarName?.takeIf { it.isNotBlank() }
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
appendAlarm(minutes, event.summary)
}
add("END:VEVENT")
}
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
event.isAllDay -> {
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
}
// Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> {
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
} else {
// Unknown zone id → fall back to plain UTC instants.
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
}
}
else -> {
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
}
}
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
add("BEGIN:VALARM")
add("ACTION:DISPLAY")
add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}")
add("TRIGGER:${triggerValue(minutes)}")
add("END:VALARM")
}
private companion object {
fun statusValue(status: EventStatus): String = when (status) {
EventStatus.Confirmed -> "CONFIRMED"
EventStatus.Tentative -> "TENTATIVE"
EventStatus.Cancelled -> "CANCELLED"
}
// iCal TRANSP is binary; only an explicitly free event is TRANSPARENT.
fun transpValue(availability: Availability): String =
if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE"
// A lead time of 0 fires at start (PT0M); anything positive is "before".
fun triggerValue(minutes: Int): String =
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
fun utcStamp(instant: Instant): String =
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
fun localStamp(instant: Instant, zone: TimeZone): String =
basic(instant.toLocalDateTime(zone))
fun utcDate(instant: Instant): String {
val dt = instant.toLocalDateTime(TimeZone.UTC)
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
}
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
)
}
}

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/**
* Prefill the create form from a single parsed `.ics` event (the "open one
* event" path). [calendarId] is left null so the form preselects the last-used
* calendar, exactly like a fresh create — the user confirms the target and
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
* handling (provider all-day times are UTC midnights with an exclusive end).
*/
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
}
return EventForm(
calendarId = null,
title = summary,
isAllDay = isAllDay,
start = start,
end = end,
location = location.orEmpty(),
description = description.orEmpty(),
reminders = reminderMinutes.distinct().sorted(),
availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
@@ -23,6 +24,7 @@ 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.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
@@ -48,6 +50,8 @@ fun CalendarHost(
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -121,6 +125,18 @@ fun CalendarHost(
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
// An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
importUri = requestedImportUri
onImportConsumed()
}
}
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
LaunchedEffect(widgetNavRequest) {
@@ -254,5 +270,26 @@ fun CalendarHost(
) {
CalendarsScreen(onBack = { showCalendars = false })
}
// Import flow for an opened/received .ics file. A single event routes
// into the create form (prefilled, for review); many open the picker.
importUri?.let { uri ->
ImportScreen(
uri = uri,
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null
importForm = form
},
)
}
importForm?.let { form ->
EventEditScreen(
initialDateIso = null,
initialForm = form,
onClose = { importForm = null },
onSaved = { importForm = null },
)
}
}
}

View File

@@ -27,6 +27,8 @@ fun RootScreen(
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -62,6 +64,8 @@ fun RootScreen(
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,

View File

@@ -5,6 +5,8 @@ import android.content.Context
import android.content.Intent
import android.provider.Settings
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -30,6 +32,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.AlertDialog
@@ -77,6 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@@ -95,6 +99,7 @@ fun CalendarsScreen(
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
@@ -131,6 +136,9 @@ fun CalendarsScreen(
synced = calendars.filterNot { it.isLocal },
error = error,
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onConsumeBackupResult = viewModel::consumeBackupResult,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
@@ -144,6 +152,9 @@ private fun CalendarsList(
synced: List<CalendarSource>,
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
@@ -159,6 +170,31 @@ private fun CalendarsList(
}
}
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let(onExportBackup) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
is BackupResult.Success -> {
snackbarHostState.showSnackbar(
context.resources.getQuantityString(
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
),
)
onConsumeBackupResult()
}
BackupResult.Failure -> {
snackbarHostState.showSnackbar(backupFailedText)
onConsumeBackupResult()
}
null -> Unit
}
}
CollapsingScaffold(
title = stringResource(R.string.calendars_title),
onBack = onBack,
@@ -195,6 +231,22 @@ private fun CalendarsList(
onClick = onAdd,
)
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something to back up.
if (local.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
)
}
Spacer(Modifier.height(16.dp))
// Synced calendars — read-only, grouped by account, each with a
@@ -429,6 +481,25 @@ private fun AccountHeader(account: String, accountType: String) {
}
}
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
@Composable
private fun LeadingAvatar(icon: ImageVector) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
@Composable
private fun AddAvatar() {

View File

@@ -1,11 +1,14 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -15,7 +18,9 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import javax.inject.Inject
/**
@@ -27,6 +32,7 @@ import javax.inject.Inject
@HiltViewModel
class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -45,6 +51,36 @@ class CalendarsViewModel @Inject constructor(
fun consumeError() { _error.value = false }
private val _backupResult = MutableStateFlow<BackupResult?>(null)
val backupResult: StateFlow<BackupResult?> = _backupResult.asStateFlow()
fun consumeBackupResult() { _backupResult.value = null }
/**
* Serialise every event of the writable local calendars into the chosen SAF
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
* in [backupResult] for a one-shot message.
*/
fun exportBackup(uri: Uri) {
viewModelScope.launch {
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents()
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),
)
events.size
}
BackupResult.Success(count)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
BackupResult.Failure
}
}
}
fun createCalendar(displayName: String, color: Int, description: String?) = write {
repository.createLocalCalendar(displayName, color, description)
}
@@ -69,3 +105,9 @@ class CalendarsViewModel @Inject constructor(
}
}
}
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
sealed interface BackupResult {
data class Success(val eventCount: Int) : BackupResult
data object Failure : BackupResult
}

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
* matches the card/grouped-row design language (not Material's outlined field).
*/
@Composable
fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
// so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
@Composable
fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
) {
Text(text = label, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(4.dp))
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}

View File

@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -102,7 +104,19 @@ fun CollapsingScaffold(
Column(
modifier = Modifier
.padding(innerPadding)
// Mark the scaffold's system-bar insets as consumed so the
// imePadding below adds only the keyboard height beyond them
// (max, not sum) — otherwise the nav-bar inset double-counts and
// leaves an empty strip above the keyboard.
.consumeWindowInsets(innerPadding)
.fillMaxSize()
// Paint the surface across the full area before imePadding carves
// into it, so any sliver above the keyboard reads as surface — not
// the dialog window's black — during the IME animation.
.background(MaterialTheme.colorScheme.surface)
// Shrink the scroll viewport by the keyboard inset so a focused
// field (e.g. the custom-reminder amount) can scroll into view.
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = 24.dp),
content = content,

View File

@@ -0,0 +1,282 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
/**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
* picker is visually identical to a Settings sub-page and uses the full width.
* [content] places the connected grouped rows; selecting one calls [onDismiss].
*/
@Composable
fun FullScreenPicker(
title: String,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
// The dialog window pans by default when the keyboard opens, which —
// combined with the content's own imePadding — leaves a fixed black gap
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
// full-screen and imePadding alone lifts the focused field.
val view = LocalView.current
SideEffect {
(view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
}
}
/**
* General single-select picker, full-screen: each option is a connected grouped
* row and the current one carries a check. Drop-in for the former dialog
* (theme, week start, language, …).
*/
@Composable
fun <T> OptionPicker(
title: String,
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = label(option),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
}
}
/**
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
* a "Custom" row that expands an inline number field plus a segmented unit
* selector. Returns the choice as a [CalendarReminderOverride].
*/
@Composable
fun ReminderDefaultPicker(
title: String,
presets: List<Int>,
selected: CalendarReminderOverride,
allowInherit: Boolean,
onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) }
var unit by rememberSaveable { mutableStateOf(seed.second) }
val options = buildList {
if (allowInherit) add(CalendarReminderOverride.Inherit)
add(CalendarReminderOverride.None)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
}
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = reminderOverrideLabel(option),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
// When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
title = if (customSelected) {
stringResource(
R.string.reminder_custom_with_value,
reminderLeadTimeLabel(selectedMinutes!!),
)
} else {
stringResource(R.string.event_edit_reminder_custom)
},
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(visible = customExpanded) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
onSelect(CalendarReminderOverride.Minutes(minutes))
onDismiss()
},
)
}
}
}
/**
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
* row above it (matching the grouped-row system, so the two read as one
* container). An amount field with a live preview of the resulting lead time, a
* single-choice unit toggle, and a tonal confirm enabled only for a valid
* 1999 amount. [onConfirm] receives the final lead time in minutes.
*/
@Composable
private fun CustomReminderEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Unit toggle first so it stays visible above the keyboard once the
// amount field (the bottom row) is focused and scrolled into view.
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
// Amount, a live preview of the lead time it resolves to, and Set —
// all on one row, sitting just above the keyboard.
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "10",
)
Spacer(Modifier.width(16.dp))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
}
@Composable
private fun SelectedCheck() {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
@Composable
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
minutes == null -> "" to ReminderUnit.Minutes
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
else -> minutes.toString() to ReminderUnit.Minutes
}

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/** Common reminder lead times offered as quick picks in the form and settings. */
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
enum class ReminderUnit(val minutesFactor: Int) {
Minutes(1),
Hours(60),
Days(1_440),
Weeks(10_080),
}
@StringRes
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
ReminderUnit.Hours -> R.string.reminder_unit_hours
ReminderUnit.Days -> R.string.reminder_unit_days
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
}
/**
* Humanise a reminder lead time (minutes before the event start) into one
* line: "Default reminder" (negative = the provider default), "At time of
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
* screen, the event form and the default-reminder settings so the wording
* never drifts.
*/
@Composable
fun reminderLeadTimeLabel(minutes: Int): String = when {
minutes < 0 -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}

View File

@@ -0,0 +1,68 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.Context
import android.content.res.Resources
import android.provider.Settings
import android.text.format.DateFormat
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalTime
/**
* M3 time picker in an alert dialog, seeded with [initial]. Shared by the event
* form (start/end times) and Settings (the all-day reminder fire time).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickerAlert(
initial: LocalTime,
onConfirm: (LocalTime) -> Unit,
onDismiss: () -> Unit,
) {
val state = rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
is24Hour = deviceUses24HourClock(LocalContext.current),
)
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
Text(stringResource(R.string.dialog_ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
text = { TimePicker(state = state) },
)
}
/**
* Whether the clock should read 24-hour, matching the rest of the device.
*
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
* the *app's* context locale — and this app applies a per-app language
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
* system 12/24 override, and otherwise fall back to the **device** locale
* (Resources.getSystem), not the app's.
*/
private fun deviceUses24HourClock(context: Context): Boolean =
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
"24" -> true
"12" -> false
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
else -> {
val deviceLocale = Resources.getSystem().configuration.locales[0]
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
}
}

View File

@@ -41,6 +41,7 @@ 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.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -59,6 +60,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -67,7 +69,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.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
import androidx.compose.ui.text.LinkAnnotation
@@ -96,6 +97,8 @@ import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -132,9 +135,30 @@ fun EventDetailScreen(
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
// an .ics in the cache and hands back a content Uri for the chooser.
val shareFailedMessage = stringResource(R.string.event_share_failed)
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
val onShareClick = {
scope.launch {
val uri = viewModel.shareUri()
val sent = uri != null && runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, shareChooserTitle))
}.isSuccess
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
}
Unit
}
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action.
var pendingEdit by remember { mutableStateOf(false) }
@@ -203,9 +227,18 @@ fun EventDetailScreen(
}
},
actions = {
// Only writable calendars get actions — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level.
val s = state
// Share works for any loaded event — it only reads the event.
if (s is EventDetailUiState.Success) {
IconButton(onClick = onShareClick) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.event_detail_share),
)
}
}
// Edit/delete need a writable calendar — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level.
if (s is EventDetailUiState.Success && s.canModify) {
IconButton(
onClick = onEditClick,
@@ -684,26 +717,7 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
@Composable
private fun reminderLeadText(reminder: Reminder): String {
val minutes = reminder.minutes
return when {
minutes < 0 -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes % 10_080 == 0 -> {
val weeks = minutes / 10_080
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
}
minutes % 1_440 == 0 -> {
val days = minutes / 1_440
pluralStringResource(R.plurals.reminder_days, days, days)
}
minutes % 60 == 0 -> {
val hours = minutes / 60
pluralStringResource(R.plurals.reminder_hours, hours, hours)
}
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
}
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/**
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
@@ -762,14 +776,19 @@ private fun formatWhen(
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) {
// All-day end is the exclusive next midnight; step back to the last
// covered day so a one-day event reads as a single date.
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
allDayLabel to dateFull.format(startLdt.toLocalDate())
// All-day events live at UTC midnights with an exclusive end. Resolve
// the covered dates in UTC — not the device zone, which would shift the
// midnight boundaries off the intended date (east of UTC pushes the
// end past the last day; west of UTC pulls the start back) — and step
// the end back to the last covered day so a one-day event reads as a
// single date.
val utc = ZoneId.of("UTC")
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
return if (startDate == lastDate) {
allDayLabel to dateFull.format(startDate)
} else {
allDayLabel to
"${dateMedium.format(startLdt.toLocalDate())} ${dateMedium.format(lastLdt.toLocalDate())}"
allDayLabel to "${dateMedium.format(startDate)} ${dateMedium.format(lastDate)}"
}
}

View File

@@ -1,14 +1,20 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -34,6 +40,7 @@ import javax.inject.Inject
@HiltViewModel
class EventDetailViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -113,6 +120,24 @@ class EventDetailViewModel @Inject constructor(
_deleteState.value = DeleteUiState.Idle
}
/**
* Serialise the open event to a `.ics` cache file and return a shareable
* content Uri (for an ACTION_SEND), or null when nothing is loaded or the
* write fails. The event is exported as a one-off (see [toShareIcsEvent]).
*/
suspend fun shareUri(): Uri? {
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
return runCatching {
withContext(io) {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
}
}.getOrNull()
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
@@ -143,3 +168,13 @@ class EventDetailViewModel @Inject constructor(
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}

View File

@@ -68,10 +68,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -86,7 +84,6 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
@@ -101,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
@@ -112,10 +110,17 @@ import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek
@@ -152,19 +157,23 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialForm: EventForm? = null,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey) {
if (editKey != null) {
viewModel.openForEdit(
LaunchedEffect(initialDateIso, editKey, initialForm) {
when {
// Single-event .ics open: the form arrives prefilled for review.
initialForm != null -> viewModel.openImported(initialForm)
editKey != null -> viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
endMillis = editKey[2],
)
} else {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date, initialStartMinutes)
else -> {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date, initialStartMinutes)
}
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -916,14 +925,7 @@ private fun FieldPickerDialog(
}
/** Quick-pick lead times offered as chips in the reminder dialog. */
private val REMINDER_QUICK_PICKS = listOf(0, 10, 30, 60, 1_440)
private enum class ReminderUnit(val minutesFactor: Int) {
Minutes(1),
Hours(60),
Days(1_440),
Weeks(10_080),
}
private val REMINDER_QUICK_PICKS = REMINDER_PRESETS
/**
* Reminder picker, two steps: the common lead times as a tappable list
@@ -1245,84 +1247,6 @@ private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMa
private fun Int.toDaySet(): Set<DayOfWeek> =
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
@Composable
private fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the dialog itself sits on
// surfaceContainerHigh, so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) {
onValueChange(text)
}
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
@Composable
private fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
start = 14.dp,
end = 8.dp,
top = 12.dp,
bottom = 12.dp,
),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Daily -> R.string.recurrence_daily
@@ -1377,13 +1301,6 @@ private fun AddReminderChip(onClick: () -> Unit) {
)
}
private fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
ReminderUnit.Hours -> R.string.reminder_unit_hours
ReminderUnit.Days -> R.string.reminder_unit_days
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
}
private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
@@ -1517,16 +1434,7 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
@Composable
private fun reminderLabel(minutes: Int): String = when {
minutes <= 0 -> stringResource(R.string.reminder_at_time)
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
/**
* One info card mirroring the detail screen's DetailCard: tonal container,
@@ -1687,31 +1595,6 @@ private fun ScheduleRow(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TimePickerAlert(
initial: LocalTime,
onConfirm: (LocalTime) -> Unit,
onDismiss: () -> Unit,
) {
val state = rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
)
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
Text(stringResource(R.string.dialog_ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
text = { TimePicker(state = state) },
)
}
@Composable
private fun CalendarPickerDialog(
calendars: List<CalendarSource>,

View File

@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
@@ -71,6 +73,10 @@ class EventEditViewModel @Inject constructor(
// 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 once the user has hand-edited the reminders on a new event, which
// freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false)
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
@@ -100,6 +106,13 @@ class EventEditViewModel @Inject constructor(
val editTarget: EditTarget?,
)
private data class ReminderDefaults(
val timed: Int?,
val allDay: Int?,
val timedOverrides: Map<Long, Int?>,
val allDayOverrides: Map<Long, Int?>,
)
private data class ExternalInputs(
val writable: List<CalendarSource>,
val lastUsed: Long?,
@@ -194,6 +207,63 @@ class EventEditViewModel @Inject constructor(
}
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
applyDefaultReminder()
}
/**
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
* the create form" path). [form] already carries the file's fields; its
* [EventForm.calendarId] is null so the calendar still resolves to the
* last-used/first-writable one, and reminders are frozen as touched so the
* settings default never overwrites what the file specified. No-op when a
* form is already open, so the prefill survives configuration changes.
*/
fun openImported(form: EventForm) {
if (_form.value != null || _editTarget.value != null) return
_remindersTouched.value = true
_revealed.value = form.populatedFields()
_form.value = form
}
/**
* Prefill a new event's reminders from the settings default — the all-day
* default for all-day events, otherwise the resolved calendar's per-calendar
* override or the global timed default. No-op while editing an existing event
* or once the user has hand-edited the reminders, so the auto-default never
* clobbers a manual choice. [calendarId] short-circuits the resolution after a
* calendar switch; null resolves it as the form does.
*/
private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return
viewModelScope.launch {
val defaults = combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
val targetId = calendarId ?: resolvedCalendarId.first()
// Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val default = resolveDefaultReminder(
timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides,
allDayOverrides = defaults.allDayOverrides,
calendarId = targetId,
isAllDay = form.isAllDay,
)
val reminders = listOfNotNull(default)
_form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) {
_revealed.value = _revealed.value + EventFormField.Reminders
}
}
}
/**
@@ -229,6 +299,7 @@ class EventEditViewModel @Inject constructor(
_revealed.value = emptySet()
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -239,14 +310,24 @@ class EventEditViewModel @Inject constructor(
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 setAllDay(value: Boolean) {
update { it.copy(isAllDay = value) }
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
}
/**
* Switching calendars drops any chosen colour: a palette key is
* account-scoped, and a raw colour may be invalid on the new calendar.
* The event falls back to the new calendar's colour until re-picked.
*/
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
fun setCalendar(id: Long) {
update { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder unless
// the user has already hand-edited it (guarded inside).
applyDefaultReminder(id)
}
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
@@ -262,12 +343,14 @@ class EventEditViewModel @Inject constructor(
/** 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 addReminder(minutes: Int) {
_remindersTouched.value = true
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
fun removeReminder(minutes: Int) = update {
it.copy(reminders = it.reminders - minutes)
fun removeReminder(minutes: Int) {
_remindersTouched.value = true
update { it.copy(reminders = it.reminders - minutes) }
}
/** Moving the start drags the end along, preserving the duration. */

View File

@@ -0,0 +1,207 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
* Handles an opened/received `.ics` file. A single event is handed straight to
* the prefilled create form via [onOpenSingle]; several events show a target-
* calendar picker and import in bulk (dedup by UID), then a result summary.
* Empty/failed files show a short message and close.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportScreen(
uri: Uri,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
viewModel: ImportViewModel = hiltViewModel(),
) {
LaunchedEffect(uri) { viewModel.load(uri) }
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(onBack = onClose)
// A single event isn't shown here — it opens the create form for review.
LaunchedEffect(state) {
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.import_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when (val s = state) {
ImportUiState.Loading,
ImportUiState.Importing,
is ImportUiState.Single,
-> CircularProgressIndicator(Modifier.align(Alignment.Center))
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
}
}
@Composable
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
// No writable calendar to import into — tell the user honestly.
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
return
}
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp),
)
Text(
stringResource(R.string.import_target_header),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
state.calendars.forEach { calendar ->
OptionCard(
label = calendar.displayName,
onClick = { selected = calendar.id },
selected = calendar.id == selected,
icon = null,
)
}
state.warnings.forEach { WarningText(it) }
Button(
onClick = { onImport(selected) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
}
}
}
@Composable
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
stringResource(R.string.import_done_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp),
)
Text(
pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
style = MaterialTheme.typography.bodyLarge,
)
if (state.summary.skippedDuplicate > 0) {
Text(
pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
Text(stringResource(R.string.import_close))
}
}
}
@Composable
private fun WarningText(warning: IcsParseWarning) {
val text = when (warning) {
IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, style = MaterialTheme.typography.bodyLarge)
if (onClose != null) {
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
}
}
}
}

View File

@@ -0,0 +1,105 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/** What an opened/received `.ics` resolved to. */
sealed interface ImportUiState {
data object Loading : ImportUiState
data object Importing : ImportUiState
/** The file held no importable event (or couldn't be read/parsed as one). */
data object Empty : ImportUiState
data object Failed : ImportUiState
/** Exactly one event → review it in the prefilled create form. */
data class Single(val form: EventForm, val warnings: Set<IcsParseWarning>) : ImportUiState
/** Several events → pick a target calendar and bulk-import. */
data class Many(
val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>,
val calendars: List<CalendarSource>,
) : ImportUiState
data class Done(val summary: IcsImportSummary) : ImportUiState
}
/**
* Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
* one event opens the create form for review, many open the bulk-import picker.
* The bulk import dedups by UID in the repository.
*/
@HiltViewModel
class ImportViewModel @Inject constructor(
private val repository: CalendarRepository,
private val importer: IcsImporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val parser = IcsParser()
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
fun load(uri: Uri) {
if (started) return
started = true
viewModelScope.launch {
val parsed = withContext(io) {
importer.readText(uri)?.let(parser::parse)
}
_state.value = when {
parsed == null -> ImportUiState.Failed
parsed.events.isEmpty() -> ImportUiState.Empty
parsed.events.size == 1 -> ImportUiState.Single(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
else -> ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first().filter { it.canModifyContents },
)
}
}
}
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
fun import(targetCalendarId: Long) {
val many = _state.value as? ImportUiState.Many ?: return
viewModelScope.launch {
_state.value = ImportUiState.Importing
_state.value = try {
ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
ImportUiState.Failed
}
}
}
}

View File

@@ -1,36 +1,78 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import de.jeanlucmakiola.calendula.R
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
/** UI-facing language choice. AUTO follows the system languages. */
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/**
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
* platform per-app-languages API; below that the appcompat backport persists
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
* it in DataStore. Setting a locale recreates the activity, which re-reads the
* current value for the dropdown.
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
*
* That file is the single source of truth for which languages we ship: dropping
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
* language show up here and in the system per-app-language settings, with no
* other code change. The system-default choice is represented as `null`.
*
* On API 33+ this delegates to the platform per-app-languages API; below that
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
* service), so we don't mirror it in DataStore. Setting a locale recreates the
* activity, which re-reads the current value for the picker.
*/
object AppLanguage {
fun current(): LanguagePref {
val locales = AppCompatDelegate.getApplicationLocales()
if (locales.isEmpty) return LanguagePref.AUTO
return when (locales[0]?.language) {
"de" -> LanguagePref.GERMAN
"en" -> LanguagePref.ENGLISH
else -> LanguagePref.AUTO
/**
* The BCP-47 tags the app ships translations for, in declaration order, as
* listed in locales_config.xml. Returns whatever could be parsed; a missing
* or malformed config yields an empty list (the picker then offers only the
* system-default entry rather than crashing).
*/
fun supportedTags(context: Context): List<String> {
val tags = mutableListOf<String>()
val parser = context.resources.getXml(R.xml.locales_config)
try {
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
}
event = parser.next()
}
} catch (_: Exception) {
// Fall back to whatever was parsed before the failure.
} finally {
parser.close()
}
return tags
}
fun apply(pref: LanguagePref) {
val locales = when (pref) {
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
fun currentTag(): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
}
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
fun apply(tag: String?) {
val locales = if (tag == null) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
/**
* The autonym for a tag — the language's own name in its own script, e.g.
* "Deutsch", "English", "Français" — so users find their language regardless
* of the current UI language. Capitalised per the language's own rules.
*/
fun displayName(tag: String): String {
val locale = Locale.forLanguageTag(tag)
return locale.getDisplayName(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
}
}

View File

@@ -5,6 +5,9 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@@ -31,20 +34,22 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -63,17 +68,28 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import kotlinx.datetime.LocalTime
import java.util.Calendar
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, EventForm, Notifications }
@@ -188,11 +204,15 @@ private fun SettingsHub(
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.current()) }
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
@@ -202,9 +222,9 @@ private fun LanguageRow(position: Position) {
)
if (showDialog) {
OptionPickerDialog(
OptionPicker(
title = stringResource(R.string.settings_language),
options = LanguagePref.entries,
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
@@ -378,7 +398,7 @@ private fun AppearanceScreen(
}
if (showTheme) {
OptionPickerDialog(
OptionPicker(
title = stringResource(R.string.settings_theme),
options = ThemeMode.entries,
selected = state.themeMode,
@@ -388,7 +408,7 @@ private fun AppearanceScreen(
)
}
if (showWeekStart) {
OptionPickerDialog(
OptionPicker(
title = stringResource(R.string.settings_week_start),
options = WeekStartPref.entries,
selected = state.weekStart,
@@ -482,6 +502,12 @@ private fun NotificationsScreen(
}
}
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
onBack = onBack,
@@ -489,13 +515,273 @@ private fun NotificationsScreen(
GroupedRow(
title = stringResource(R.string.settings_reminders),
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Alone,
position = Position.Top,
trailing = {
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
},
onClick = { toggleReminders(!state.remindersEnabled) },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
position = Position.Middle,
onClick = { showDefaultReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
position = Position.Middle,
onClick = { showAllDayReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_allday_reminder_time),
summary = stringResource(
R.string.settings_allday_reminder_time_hint,
formatTimeOfDay(context, state.allDayReminderTimeMinutes),
),
position = Position.Bottom,
onClick = { showAllDayReminderTime = true },
)
// Per-calendar overrides: each writable calendar may keep, drop, or
// replace the global default — separately for timed and all-day events.
if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.settings_calendar_reminders_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list of three
// (the card + the timed and all-day override rows).
GroupedRow(
title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = {
expandedCalendars = if (expanded) {
expandedCalendars - calendar.id
} else {
expandedCalendars + calendar.id
}
},
)
AnimatedVisibility(visible = expanded) {
Column {
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
)
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
)
}
}
}
}
// Delivery reliability: Android's battery optimisation can delay or drop
// the calendar provider's reminder broadcast. A soft, optional exemption
// (system-settings deep-link, no special permission) improves on-time
// delivery; shown as live status, reversible by the user at any time.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Alone,
trailing = if (batteryExempt) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
}
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
onDismiss = { showDefaultReminder = false },
)
}
if (showAllDayReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder_allday),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
onDismiss = { showAllDayReminder = false },
)
}
if (showAllDayReminderTime) {
TimePickerAlert(
initial = LocalTime(
state.allDayReminderTimeMinutes / 60,
state.allDayReminderTimeMinutes % 60,
),
onConfirm = {
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
showAllDayReminderTime = false
},
onDismiss = { showAllDayReminderTime = false },
)
}
overrideDialog?.let { target ->
val map = if (target.isAllDay) {
state.perCalendarAllDayReminderOverride
} else {
state.perCalendarReminderOverride
}
ReminderDefaultPicker(
title = stringResource(
if (target.isAllDay) {
R.string.settings_default_reminder_allday
} else {
R.string.settings_default_reminder
},
),
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
selected = map.choiceFor(target.calendarId),
allowInherit = true,
onSelect = {
if (target.isAllDay) {
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
} else {
viewModel.setCalendarReminderOverride(target.calendarId, it)
}
},
onDismiss = { overrideDialog = null },
)
}
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (null = none) as a picker choice for selection highlighting. */
private fun Int?.toReminderChoice(): CalendarReminderOverride =
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
(this as? CalendarReminderOverride.Minutes)?.minutes
/**
* Whether Calendula is exempt from battery optimisation, re-read on every
* `ON_RESUME` so the row reflects a change the user just made in system
* settings without needing to leave and re-enter the screen.
*/
@Composable
private fun rememberBatteryOptimizationExempt(): Boolean {
val context = LocalContext.current
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
exempt = isIgnoringBatteryOptimizations(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
return exempt
}
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return power.isIgnoringBatteryOptimizations(context.packageName)
}
/**
* Take the user straight to Calendula's exemption: the direct
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
* battery optimisation?") rather than the full app list they'd have to scroll.
* Falls back to the optimisation list if the OS refuses the direct intent.
*/
private fun openBatteryOptimizationSettings(context: Context) {
val direct = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${context.packageName}".toUri(),
)
if (runCatching { context.startActivity(direct) }.isFailure) {
runCatching {
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
}
/**
* Lead times offered for the all-day default — day-scale, since a "minutes
* before midnight" reminder on an all-day event is rarely what's wanted.
*/
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
val time = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, minutesOfDay / 60)
set(Calendar.MINUTE, minutesOfDay % 60)
}.time
return DateFormat.getTimeFormat(context).format(time)
}
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
this[calendarId] == null -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
}
/** Label for a global-default choice: null → "None", else the lead time. */
@Composable
private fun reminderChoiceLabel(minutes: Int?): String =
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
/** Row summary for a calendar: its override, or the inherited global default. */
@Composable
private fun calendarOverrideSummary(
choice: CalendarReminderOverride,
globalDefault: Int?,
): String = when (choice) {
CalendarReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
}
// ---------------------------------------------------------------------------
@@ -531,38 +817,6 @@ private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
}
}
/** OptionCard selection dialog — the app's only sanctioned picker style. */
@Composable
private fun <T> OptionPickerDialog(
title: String,
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { option ->
OptionCard(
label = label(option),
onClick = {
onSelect(option)
onDismiss()
},
selected = option == selected,
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
@@ -598,10 +852,5 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
)
@Composable
private fun languageLabel(pref: LanguagePref): String = stringResource(
when (pref) {
LanguagePref.AUTO -> R.string.settings_language_auto
LanguagePref.GERMAN -> R.string.settings_language_german
LanguagePref.ENGLISH -> R.string.settings_language_english
},
)
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)

View File

@@ -3,6 +3,7 @@ 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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
/**
@@ -20,6 +21,25 @@ data class SettingsUiState(
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true,
/**
* The default reminder lead time (minutes) prefilled on new timed events;
* null = no default reminder. Per-calendar overrides take precedence.
*/
val defaultReminderMinutes: Int? = null,
/** The default reminder lead time prefilled on new all-day events; null = none. */
val defaultAllDayReminderMinutes: Int? = null,
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (null value = no
* reminder); absent = inherit the global default.
*/
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).

View File

@@ -4,13 +4,19 @@ import android.os.Build
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.prefs.CalendarReminderOverride
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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -18,14 +24,20 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
repository: CalendarRepository,
) : ViewModel() {
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/** Writable calendars — the only ones that take a per-calendar reminder override. */
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(
// combine() only types up to five flows, so the sixth pref folds
// into the assembled state in an outer combine.
// combine() types up to five flows, so the prefs split into two
// groups that fold together in the outer combine.
combine(
prefs.themeMode,
prefs.dynamicColor,
@@ -42,15 +54,50 @@ class SettingsViewModel @Inject constructor(
remindersEnabled = reminders,
)
},
prefs.allowColorOnUnsupportedCalendars,
) { base, allowColor ->
base.copy(allowColorOnUnsupportedCalendars = allowColor)
combine(
prefs.allowColorOnUnsupportedCalendars,
prefs.defaultReminderMinutes,
prefs.defaultAllDayReminderMinutes,
prefs.allDayReminderTimeMinutes,
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
},
combine(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
) { base, defaults, overrides ->
base.copy(
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
allDayReminderTimeMinutes = defaults.allDayReminderTime,
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
private data class ReminderDefaults(
val allowColor: Boolean,
val defaultReminder: Int?,
val allDayReminder: Int?,
val allDayReminderTime: Int,
)
private data class ReminderOverrides(
val timed: Map<Long, Int?>,
val allDay: Map<Long, Int?>,
val calendars: List<CalendarSource>,
)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
@@ -71,6 +118,26 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
}
fun setDefaultReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
}
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
}
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
}
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
}
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
}
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
}

View File

@@ -181,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
if (isAllDay) {
// All-day events live at UTC midnights with an exclusive end. Compare
// calendar dates in UTC and step the exclusive end back to the last
// covered day (mirroring the detail/edit views), so a one-day event
// covers exactly its single date. Slicing the day in the device zone
// would push the exclusive end a few hours into the next local day
// east of UTC, making the event leak onto day + 1.
val startDate = start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
return day in startDate..lastDay
}
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return start < dayEnd && end > dayStart

View File

@@ -47,6 +47,9 @@
<string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
<string name="event_detail_delete">Löschen</string>
<string name="event_detail_share">Teilen</string>
<string name="event_share_chooser_title">Termin teilen</string>
<string name="event_share_failed">Termin konnte nicht geteilt werden.</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>
@@ -248,14 +251,26 @@
<string name="settings_section_notifications">Benachrichtigungen</string>
<string name="settings_reminders">Termin-Erinnerungen</string>
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
<string name="settings_default_reminder">Standard-Erinnerung</string>
<string name="settings_default_reminder_allday">Ganztägige Termine</string>
<string name="settings_allday_reminder_time">Uhrzeit für ganztägige Erinnerungen</string>
<string name="settings_allday_reminder_time_hint">Erinnerungen für ganztägige Termine werden um %1$s ausgelöst</string>
<string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
<string name="reminder_custom_amount">Anzahl</string>
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
<string name="reminder_custom_set">Übernehmen</string>
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
<string name="settings_section_calendars">Kalender</string>
<string name="settings_manage_calendars">Kalender verwalten</string>
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
<string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
@@ -285,4 +300,41 @@
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt.</string>
<string name="calendars_write_error">Änderung konnte nicht gespeichert werden.</string>
<!-- Backup (whole-calendar .ics export) -->
<string name="calendars_backup_header">Sicherung</string>
<string name="calendars_backup_hint">Lokale Kalender werden nirgends synchronisiert exportiere sie als .ics-Datei, um eine Kopie zu behalten.</string>
<string name="calendars_backup_action">Als .ics-Datei exportieren</string>
<string name="calendars_backup_failed">Sicherung konnte nicht exportiert werden.</string>
<plurals name="calendars_backup_done">
<item quantity="one">%d Termin exportiert.</item>
<item quantity="other">%d Termine exportiert.</item>
</plurals>
<!-- Import (.ics) -->
<string name="import_title">Termine importieren</string>
<string name="import_target_header">Zu Kalender hinzufügen</string>
<string name="import_empty">In dieser Datei wurden keine Termine gefunden.</string>
<string name="import_failed">Datei konnte nicht gelesen werden.</string>
<string name="import_no_calendar">Kein beschreibbarer Kalender zum Importieren. Lege zuerst einen lokalen Kalender an.</string>
<string name="import_done_title">Import abgeschlossen</string>
<string name="import_close">Schließen</string>
<string name="import_warning_recurrence">Einige geänderte Einzeltermine wiederkehrender Termine wurden übersprungen.</string>
<string name="import_warning_no_start">Ein Termin ohne Startzeit wurde übersprungen.</string>
<string name="import_warning_attendees">Gästelisten wurden nicht importiert.</string>
<string name="import_warning_timezone">Eine unbekannte Zeitzone wurde durch die deines Geräts ersetzt.</string>
<plurals name="import_event_count">
<item quantity="one">%d Termin in dieser Datei.</item>
<item quantity="other">%d Termine in dieser Datei.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">%d Termin importieren</item>
<item quantity="other">%d Termine importieren</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">%d Termin importiert.</item>
<item quantity="other">%d Termine importiert.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
</plurals>
</resources>

View File

@@ -0,0 +1,6 @@
<resources>
<!-- Dark-scheme window backdrop, matching the Compose dark background/surface
(#101316) so activity recreation (e.g. language switch) doesn't flash a
lighter grey. See values/colors.xml. -->
<color name="window_background">#FF101316</color>
</resources>

View File

@@ -3,4 +3,8 @@
<color name="seed">#FF5C6B7A</color>
<!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF5C6B7A</color>
<!-- Window backdrop shown during activity recreation (e.g. on a language
switch). Matches the Compose light scheme background/surface so the
recreation is seamless; overridden for dark in values-night. -->
<color name="window_background">#FFFBFCFE</color>
</resources>

View File

@@ -48,6 +48,9 @@
<string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string>
<string name="event_detail_share">Share</string>
<string name="event_share_chooser_title">Share event</string>
<string name="event_share_failed">Couldn\'t share this event.</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>
@@ -245,14 +248,26 @@
<string name="settings_section_notifications">Notifications</string>
<string name="settings_reminders">Event reminders</string>
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
<string name="settings_default_reminder">Default reminder</string>
<string name="settings_default_reminder_allday">All-day events</string>
<string name="settings_allday_reminder_time">All-day reminder time</string>
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
<string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
<string name="settings_section_calendars">Calendars</string>
<string name="settings_manage_calendars">Manage calendars</string>
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
<string name="settings_section_language">Language</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
@@ -282,6 +297,43 @@
<string name="calendars_delete_confirm_title">Delete calendar?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
<string name="calendars_write_error">Couldn\'t save the change.</string>
<!-- Backup (whole-calendar .ics export) -->
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
<string name="calendars_backup_action">Export as .ics file</string>
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Exported %d event.</item>
<item quantity="other">Exported %d events.</item>
</plurals>
<!-- Import (.ics) -->
<string name="import_title">Import events</string>
<string name="import_target_header">Add to calendar</string>
<string name="import_empty">No events found in this file.</string>
<string name="import_failed">Couldn\'t read this file.</string>
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
<string name="import_done_title">Import complete</string>
<string name="import_close">Close</string>
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
<string name="import_warning_no_start">An event without a start time was skipped.</string>
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
<plurals name="import_event_count">
<item quantity="one">%d event in this file.</item>
<item quantity="other">%d events in this file.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Import %d event</item>
<item quantity="other">Import %d events</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Imported %d event.</item>
<item quantity="other">Imported %d events.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Skipped %d already in this calendar.</item>
<item quantity="other">Skipped %d already in this calendar.</item>
</plurals>
<!-- Launcher long-press shortcuts -->
<string name="shortcut_new_event_short">New event</string>
<string name="shortcut_new_event_long">Create a new event</string>

View File

@@ -1,5 +1,10 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Calendula" parent="android:Theme.Material.Light.NoActionBar">
<!-- AppCompat (DayNight) parent so MainActivity can be an AppCompatActivity,
which is required for AppCompatDelegate.setApplicationLocales (the in-app
language picker) to sync to the system. Actual colours are driven by the
Compose theme; this is essentially the launch/backdrop theme. -->
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Exposes the cache subdirectory where IcsExporter stages files for sharing. -->
<paths>
<cache-path
name="shared_ics"
path="shared_ics/" />
</paths>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The languages Calendula ships translations for. This is the single source of
truth: each entry must have a matching res/values-<tag>/strings.xml, and is
surfaced automatically in both the in-app language picker (parsed at runtime
by AppLanguage) and the system per-app language settings (Android 13+, via
android:localeConfig in the manifest). To add a community translation, drop
in the values-<tag> folder and add one <locale> line here.
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
</locale-config>

View File

@@ -0,0 +1,97 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
class AllDayReminderEncodingTest {
private val berlin: ZoneId = ZoneId.of("Europe/Berlin")
private val nineAm = 9 * 60
private val summer = LocalDate.of(2026, 6, 20) // CEST, UTC+2
private val winter = LocalDate.of(2026, 1, 20) // CET, UTC+1
/** The instant the provider would actually fire: DTSTART(UTC midnight) raw. */
private fun actualFire(rawMinutes: Int, startDate: LocalDate): Long =
startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() -
rawMinutes * 60_000L
/** The wall-clock instant we intend: [time] local, [daysBefore] days before [startDate]. */
private fun intendedFire(startDate: LocalDate, daysBefore: Int, timeMinutes: Int): Long =
startDate.minusDays(daysBefore.toLong())
.atTime(LocalTime.of(timeMinutes / 60, timeMinutes % 60))
.atZone(berlin).toInstant().toEpochMilli()
@Test
fun `one day before at 9am fires at 9am local the day before (summer)`() {
val raw = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 1, nineAm))
// 09:00 CEST is 07:00Z, 7h later than the bare midnight offset: 1440 420.
assertThat(raw).isEqualTo(1_020)
}
@Test
fun `one day before at 9am fires at 9am local the day before (winter)`() {
val raw = toProviderAllDayMinutes(1_440, winter, berlin, nineAm)
assertThat(actualFire(raw, winter)).isEqualTo(intendedFire(winter, 1, nineAm))
// 09:00 CET is 08:00Z, 8h later than midnight: 1440 480.
assertThat(raw).isEqualTo(960)
}
@Test
fun `at time of event encodes a negative offset firing 9am on the day (summer)`() {
val raw = toProviderAllDayMinutes(0, summer, berlin, nineAm)
assertThat(raw).isLessThan(0) // fires after DTSTART; must not be clamped
assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 0, nineAm))
}
@Test
fun `round-trips for whole-day lead times across both seasons`() {
for (date in listOf(summer, winter)) {
for (time in listOf(0, nineAm, 20 * 60)) {
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
assertThat(fromProviderAllDayMinutes(raw, date, berlin)).isEqualTo(semantic)
}
}
}
}
@Test
fun `pre-feature rows (raw multiple of 1440) still decode to whole days`() {
// Reminders written before this feature stored raw N*1440 (fired at UTC
// midnight). They must still read back as "N days before".
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin)).isEqualTo(2_880)
}
@Test
fun `decoding is independent of the time-of-day used to encode`() {
val atNine = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
val atEight = toProviderAllDayMinutes(1_440, summer, berlin, 8 * 60)
assertThat(atNine).isNotEqualTo(atEight)
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin)).isEqualTo(1_440)
}
@Test
fun `negative semantic minutes (provider-default sentinel) pass through`() {
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
}
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An
// offset tuned for a CET anchor fires an hour off once the series crosses
// into CEST. Bounded to ±1h; documented, not fixed.
val raw = toProviderAllDayMinutes(1_440, winter, berlin, nineAm)
val summerOccurrence = LocalDate.of(2026, 7, 20)
val fire = actualFire(raw, summerOccurrence).let(java.time.Instant::ofEpochMilli)
.atZone(berlin).toLocalTime()
assertThat(fire).isEqualTo(LocalTime.of(10, 0)) // 09:00 intended, +1h in CEST
}
}

View File

@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -28,6 +29,13 @@ class CalendarRepositoryImplTest {
private fun newPrefs(tempDir: Path): CalendarPrefs =
CalendarPrefs(newDataStore(tempDir))
private fun newSettings(tempDir: Path): SettingsPrefs =
SettingsPrefs(
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("repo_test_settings.preferences_pb").toFile() },
),
)
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
@@ -53,7 +61,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
val first = awaitItem()
@@ -67,7 +75,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.id }).containsExactly(1L)
@@ -91,7 +99,7 @@ class CalendarRepositoryImplTest {
listOf(makeEvent(10L))
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
repo.instances(range).test {
@@ -107,7 +115,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -129,7 +137,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -149,7 +157,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -165,7 +173,7 @@ 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 repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Stand-up",
@@ -179,12 +187,32 @@ class CalendarRepositoryImplTest {
assertThat(fake.insertedForms).containsExactly(form)
}
@Test
fun `createEvent passes the configured all-day reminder time to the data source`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val settings = newSettings(tempDir)
settings.setAllDayReminderTimeMinutes(8 * 60) // 08:00
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), settings, Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
)
repo.createEvent(form)
assertThat(fake.allDayReminderTimes).containsExactly(480)
}
@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 repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
@@ -202,7 +230,7 @@ class CalendarRepositoryImplTest {
@Test
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
@@ -221,7 +249,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("update event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
@@ -239,7 +267,7 @@ class CalendarRepositoryImplTest {
@Test
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.deleteEvent(eventId = 42L)
@@ -250,7 +278,7 @@ class CalendarRepositoryImplTest {
@Test
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
@@ -261,7 +289,7 @@ class CalendarRepositoryImplTest {
@Test
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
@@ -273,7 +301,7 @@ class CalendarRepositoryImplTest {
@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 repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Moved",
@@ -291,7 +319,7 @@ class CalendarRepositoryImplTest {
@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 repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Weekly",
@@ -318,7 +346,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("delete event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
try {
repo.deleteEvent(eventId = 42L)
@@ -331,7 +359,7 @@ class CalendarRepositoryImplTest {
@Test
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextCalendarId = 501L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val id = repo.createLocalCalendar(
displayName = "Home",
@@ -348,7 +376,7 @@ class CalendarRepositoryImplTest {
@Test
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.updateCalendar(
id = 5L,
@@ -365,7 +393,7 @@ class CalendarRepositoryImplTest {
@Test
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.deleteCalendar(id = 7L)
@@ -377,7 +405,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("create local calendar 'Home'")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
try {
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
@@ -392,7 +420,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { null }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
try {
repo.eventDetail(eventId = 999L)
@@ -411,10 +439,41 @@ class CalendarRepositoryImplTest {
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
assertThat(repo.eventColorPalette(7L))
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
assertThat(repo.eventColorPalette(8L)).isEmpty()
}
@Test
fun `importEvents skips events whose UID already exists and inserts the rest`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
existingUidsResult = setOf("dup@x")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val events = listOf(
parsedEvent("dup@x"), // already present → skipped
parsedEvent("new@x"), // inserted
parsedEvent(null), // no UID → always inserted
)
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
assertThat(summary.imported).isEqualTo(2)
assertThat(summary.skippedDuplicate).isEqualTo(1)
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("new@x", null)
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,
zoneId = "UTC",
)
}

View File

@@ -5,6 +5,8 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
@@ -16,6 +18,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
/** UIDs the target calendar already holds, for import dedup. */
var existingUidsResult: Set<String> = emptySet()
/** Set to make the next write call throw. */
var writeError: Exception? = null
/** Id returned by the next [insertEvent]. */
@@ -49,6 +54,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
writeError?.let { throw it }
importedEvents += event to calendarId
return nextInsertId
}
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
writeError?.let { throw it }
@@ -66,20 +83,36 @@ internal class FakeCalendarDataSource : CalendarDataSource {
deletedCalendarIds += id
}
override fun insertEvent(form: EventForm): Long {
/** All-day reminder fire-time minute-of-day passed into the last write. */
val allDayReminderTimes = mutableListOf<Int>()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
writeError?.let { throw it }
insertedForms += form
allDayReminderTimes += allDayReminderTimeMinutes
return nextInsertId
}
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
override fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
) {
writeError?.let { throw it }
updatedEvents += Triple(eventId, original, updated)
allDayReminderTimes += allDayReminderTimeMinutes
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
writeError?.let { throw it }
updatedOccurrences += Triple(eventId, beginMillis, form)
allDayReminderTimes += allDayReminderTimeMinutes
return nextInsertId
}
@@ -88,9 +121,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
beginMillis: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
writeError?.let { throw it }
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
allDayReminderTimes += allDayReminderTimeMinutes
return nextInsertId
}

View File

@@ -0,0 +1,70 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventStatus
import org.junit.jupiter.api.Test
class IcsExportMapperTest {
@Test
fun `timed one-off row maps with its DTEND and kept UID`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 42L,
EventExportProjection.IDX_UID to "abc@host",
EventExportProjection.IDX_TITLE to "Standup",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DTEND to 1_900_000L,
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
)
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
assertThat(event.uid).isEqualTo("abc@host")
assertThat(event.summary).isEqualTo("Standup")
assertThat(event.start.toEpochMilliseconds()).isEqualTo(1_000_000L)
assertThat(event.end.toEpochMilliseconds()).isEqualTo(1_900_000L)
assertThat(event.isAllDay).isFalse()
assertThat(event.recurrenceRule).isNull()
assertThat(event.reminderMinutes).containsExactly(10)
assertThat(event.calendarName).isEqualTo("Personal")
assertThat(event.status).isEqualTo(EventStatus.Confirmed)
}
@Test
fun `recurring row without DTEND reconstructs end from DURATION`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 7L,
// No UID column → synthesised stably from id + dtstart.
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
// DTEND absent (null); DURATION carries the length.
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
assertThat(event.uid).isEqualTo("7-1000000@calendula")
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
assertThat(event.end.toEpochMilliseconds()).isEqualTo(1_000_000L + 3_600_000L)
}
@Test
fun `all-day flag is carried through`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 1L,
EventExportProjection.IDX_TITLE to "Holiday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DTEND to 86_400_000L,
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
}
}

View File

@@ -121,6 +121,118 @@ class SettingsPrefsTest {
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
}
@Test
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultReminderMinutes.first()).isNull()
}
@Test
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setDefaultReminderMinutes(30)
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
prefs.setDefaultReminderMinutes(null)
assertThat(prefs.defaultReminderMinutes.first()).isNull()
}
@Test
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
m
}
assertThat(prefs.defaultReminderMinutes.first()).isNull()
}
@Test
fun `per-calendar override round-trips minutes, none, and inherit`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15, 9L, null)
}
// Inherit drops the override entirely (absent != null value).
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15)
assertThat(map.containsKey(9L)).isFalse()
}
}
@Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
prefs.setDefaultAllDayReminderMinutes(1_440)
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
prefs.setDefaultAllDayReminderMinutes(null)
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
}
@Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
// Clearing the all-day override leaves the timed one untouched.
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
}
@Test
fun `resolveDefaultReminder picks the kind-matching override or global`() {
val timed = mapOf(7L to 15, 9L to null)
val allDay = mapOf(7L to 2_880)
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
timedGlobal = 30,
allDayGlobal = 1_440,
timedOverrides = timed,
allDayOverrides = allDay,
calendarId = calendarId,
isAllDay = isAllDay,
)
// Timed: minutes override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
assertThat(resolve(9L, isAllDay = false)).isNull()
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
// All-day: its own override wins; absent → all-day global; a timed-only
// override (cal 9) does not bleed into all-day.
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
}
@Test
fun `all-day reminder time defaults to 9am`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(540)
}
@Test
fun `all-day reminder time round-trips and clamps to a valid minute-of-day`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setAllDayReminderTimeMinutes(8 * 60 + 30)
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(510)
prefs.setAllDayReminderTimeMinutes(5_000)
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(1_439)
prefs.setAllDayReminderTimeMinutes(-10)
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)

View File

@@ -0,0 +1,28 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsDurationTest {
@Test
fun `parses the single-unit forms Calendula writes plus general ones`() {
assertThat(parseRfc2445DurationMillis("P1D")).isEqualTo(86_400_000L)
assertThat(parseRfc2445DurationMillis("P3600S")).isEqualTo(3_600_000L)
assertThat(parseRfc2445DurationMillis("PT1H30M")).isEqualTo(5_400_000L)
assertThat(parseRfc2445DurationMillis("P1W")).isEqualTo(604_800_000L)
}
@Test
fun `is sign-aware for before-start VALARM triggers`() {
assertThat(parseRfc2445DurationMillis("-PT15M")).isEqualTo(-900_000L)
assertThat(parseRfc2445DurationMillis("PT0M")).isEqualTo(0L)
}
@Test
fun `unparseable input is zero`() {
assertThat(parseRfc2445DurationMillis(null)).isEqualTo(0L)
assertThat(parseRfc2445DurationMillis("")).isEqualTo(0L)
assertThat(parseRfc2445DurationMillis("nonsense")).isEqualTo(0L)
}
}

View File

@@ -0,0 +1,168 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class IcsParserTest {
private val parser = IcsParser(deviceZone = TimeZone.of("Europe/Berlin"))
private val writer = IcsWriter()
private val stamp = LocalDateTime(2026, 6, 18, 9, 30, 0).toInstant(TimeZone.UTC)
private fun roundTrip(event: IcsEvent): ParsedIcsEvent {
val text = writer.writeCalendar(listOf(event), stamp)
val result = parser.parse(text)
assertThat(result.events).hasSize(1)
return result.events.single()
}
private fun instantUtc(y: Int, mo: Int, d: Int, h: Int, mi: Int): Instant =
LocalDateTime(y, mo, d, h, mi, 0).toInstant(TimeZone.UTC)
@Test
fun `round-trips a timed one-off event`() {
val event = IcsEvent(
uid = "u1@calendula",
summary = "Lunch; with, friends",
start = instantUtc(2026, 6, 18, 11, 0),
end = instantUtc(2026, 6, 18, 12, 0),
isAllDay = false,
zoneId = "Europe/Berlin",
location = "Café",
availability = Availability.Free,
status = EventStatus.Tentative,
)
val parsed = roundTrip(event)
assertThat(parsed.uid).isEqualTo("u1@calendula")
assertThat(parsed.summary).isEqualTo("Lunch; with, friends")
assertThat(parsed.start).isEqualTo(event.start)
assertThat(parsed.end).isEqualTo(event.end)
assertThat(parsed.isAllDay).isFalse()
assertThat(parsed.location).isEqualTo("Café")
assertThat(parsed.availability).isEqualTo(Availability.Free)
assertThat(parsed.status).isEqualTo(EventStatus.Tentative)
}
@Test
fun `round-trips a recurring TZID event to the same instant`() {
val event = IcsEvent(
uid = "u2@calendula",
summary = "Weekly",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 14, 0),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = "FREQ=WEEKLY",
)
val parsed = roundTrip(event)
assertThat(parsed.start).isEqualTo(event.start)
assertThat(parsed.end).isEqualTo(event.end)
assertThat(parsed.zoneId).isEqualTo("Europe/Berlin")
assertThat(parsed.recurrenceRule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `round-trips an all-day event`() {
val event = IcsEvent(
uid = "u3@calendula",
summary = "Holiday",
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
isAllDay = true,
zoneId = "UTC",
)
val parsed = roundTrip(event)
assertThat(parsed.isAllDay).isTrue()
assertThat(parsed.start).isEqualTo(event.start)
assertThat(parsed.end).isEqualTo(event.end)
}
@Test
fun `round-trips reminders as before-start lead minutes`() {
val event = IcsEvent(
uid = "u4@calendula",
summary = "Meeting",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 14, 0),
isAllDay = false,
zoneId = "UTC",
reminderMinutes = listOf(15, 0),
)
val parsed = roundTrip(event)
assertThat(parsed.reminderMinutes).containsExactly(15, 0)
}
@Test
fun `tolerates folded lines and a missing UID`() {
val ics = buildString {
append("BEGIN:VCALENDAR\r\n")
append("VERSION:2.0\r\n")
append("BEGIN:VEVENT\r\n")
// Folded DESCRIPTION (continuation line begins with a space).
append("DESCRIPTION:This is a long descriptio\r\n n that was folded\r\n")
append("SUMMARY:No UID here\r\n")
append("DTSTART:20260618T090000Z\r\n")
append("DTEND:20260618T100000Z\r\n")
append("END:VEVENT\r\n")
append("END:VCALENDAR\r\n")
}
val parsed = parser.parse(ics).events.single()
assertThat(parsed.uid).isNull()
assertThat(parsed.description).isEqualTo("This is a long description that was folded")
assertThat(parsed.summary).isEqualTo("No UID here")
}
@Test
fun `skips a RECURRENCE-ID override and reports it`() {
val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\n" +
"RECURRENCE-ID:20260618T090000Z\r\nDTSTART:20260618T090000Z\r\n" +
"SUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
val result = parser.parse(ics)
assertThat(result.events).isEmpty()
assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
}
@Test
fun `reports ignored attendees but still imports the event`() {
val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:y\r\n" +
"DTSTART:20260618T090000Z\r\nSUMMARY:Has guests\r\n" +
"ATTENDEE;CN=Bob:mailto:bob@example.com\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
val result = parser.parse(ics)
assertThat(result.events).hasSize(1)
assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
}
@Test
fun `parses multiple events and carries the calendar name`() {
val events = listOf(
IcsEvent("a@c", "One", instantUtc(2026, 6, 18, 9, 0), instantUtc(2026, 6, 18, 10, 0),
false, "UTC", calendarName = "Personal"),
IcsEvent("b@c", "Two", instantUtc(2026, 6, 19, 9, 0), instantUtc(2026, 6, 19, 10, 0),
false, "UTC", calendarName = "Personal"),
)
val text = writer.writeCalendar(events, stamp)
val result = parser.parse(text)
assertThat(result.events).hasSize(2)
assertThat(result.events.map { it.calendarName }).containsExactly("Personal", "Personal")
}
@Test
fun `a malformed event does not sink the rest of the file`() {
// First VEVENT has no DTSTART (skipped); second is valid.
val ics = "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:No start\r\nEND:VEVENT\r\n" +
"BEGIN:VEVENT\r\nUID:good\r\nDTSTART:20260618T090000Z\r\nSUMMARY:Fine\r\nEND:VEVENT\r\n" +
"END:VCALENDAR\r\n"
val result = parser.parse(ics)
assertThat(result.events.map { it.uid }).containsExactly("good")
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
}
}

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsTextTest {
@Test
fun `escapes backslash semicolon comma and newline`() {
assertThat(escapeText("a\\b;c,d\ne"))
.isEqualTo("a\\\\b\\;c\\,d\\ne")
}
@Test
fun `backslash is escaped before its escape markers, not after`() {
// A single backslash must become exactly one escaped backslash, not
// accidentally combine with a following separator.
assertThat(escapeText("\\;")).isEqualTo("\\\\\\;")
}
@Test
fun `short line is returned unfolded`() {
val line = "SUMMARY:short"
assertThat(foldLine(line)).isEqualTo(line)
}
@Test
fun `long line folds into physical lines of at most 75 octets`() {
val line = "DESCRIPTION:" + "x".repeat(300)
val folded = foldLine(line)
val physical = folded.split(ICS_CRLF)
assertThat(physical.size).isGreaterThan(1)
physical.forEach { assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75) }
// Every continuation line begins with the single folding space.
physical.drop(1).forEach { assertThat(it).startsWith(" ") }
}
@Test
fun `unfolding a folded line restores the original`() {
val line = "DESCRIPTION:" + "abcdefg ".repeat(40).trim()
val unfolded = foldLine(line).replace(ICS_CRLF + " ", "")
assertThat(unfolded).isEqualTo(line)
}
@Test
fun `folding never splits a multi-byte character`() {
// 100 emoji (4 UTF-8 octets each) — a naive byte split would corrupt one.
val line = "X-NOTE:" + "😀".repeat(100)
val folded = foldLine(line)
// The reassembled content must still decode to the same string.
assertThat(folded.replace(ICS_CRLF + " ", "")).isEqualTo(line)
folded.split(ICS_CRLF).forEach {
assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75)
}
}
}

View File

@@ -0,0 +1,152 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class IcsWriterTest {
private val writer = IcsWriter(prodId = "-//Test//Test//EN")
private val stamp = LocalDateTime(2026, 6, 18, 9, 30, 0).toInstant(TimeZone.UTC)
private fun lines(events: List<IcsEvent>): List<String> =
writer.writeCalendar(events, stamp).split(ICS_CRLF)
private fun instantUtc(y: Int, mo: Int, d: Int, h: Int, mi: Int): Instant =
LocalDateTime(y, mo, d, h, mi, 0).toInstant(TimeZone.UTC)
@Test
fun `calendar is wrapped with the required header and CRLF endings`() {
val out = writer.writeCalendar(emptyList(), stamp)
assertThat(out).startsWith("BEGIN:VCALENDAR\r\n")
assertThat(out).endsWith("END:VCALENDAR\r\n")
assertThat(out).contains("VERSION:2.0\r\n")
assertThat(out).contains("PRODID:-//Test//Test//EN\r\n")
}
@Test
fun `timed one-off event writes UTC instants with a Z suffix`() {
val event = IcsEvent(
uid = "u1@calendula",
summary = "Standup",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 13, 30),
isAllDay = false,
zoneId = "Europe/Berlin",
)
val l = lines(listOf(event))
assertThat(l).contains("DTSTART:20260618T130000Z")
assertThat(l).contains("DTEND:20260618T133000Z")
assertThat(l).contains("UID:u1@calendula")
assertThat(l).contains("STATUS:CONFIRMED")
assertThat(l).contains("TRANSP:OPAQUE")
}
@Test
fun `recurring timed event anchors to wall-clock with TZID`() {
// 13:00 UTC == 15:00 Berlin in summer; the series must read 15:00 local.
val event = IcsEvent(
uid = "u2@calendula",
summary = "Weekly",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 14, 0),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = "FREQ=WEEKLY",
)
val l = lines(listOf(event))
assertThat(l).contains("DTSTART;TZID=Europe/Berlin:20260618T150000")
assertThat(l).contains("DTEND;TZID=Europe/Berlin:20260618T160000")
assertThat(l).contains("RRULE:FREQ=WEEKLY")
}
@Test
fun `recurring event with an unknown zone falls back to UTC instants`() {
val event = IcsEvent(
uid = "u3@calendula",
summary = "Weekly",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 14, 0),
isAllDay = false,
zoneId = "Mars/Olympus",
recurrenceRule = "FREQ=WEEKLY",
)
val l = lines(listOf(event))
assertThat(l).contains("DTSTART:20260618T130000Z")
assertThat(l).contains("DTEND:20260618T140000Z")
}
@Test
fun `all-day event writes exclusive DATE values without a zone`() {
val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC)
val end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC)
val event = IcsEvent(
uid = "u4@calendula",
summary = "Holiday",
start = start,
end = end,
isAllDay = true,
zoneId = "UTC",
)
val l = lines(listOf(event))
assertThat(l).contains("DTSTART;VALUE=DATE:20260618")
assertThat(l).contains("DTEND;VALUE=DATE:20260619")
}
@Test
fun `reminders become VALARM blocks with before-start triggers`() {
val event = IcsEvent(
uid = "u5@calendula",
summary = "Meeting",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 14, 0),
isAllDay = false,
zoneId = "UTC",
reminderMinutes = listOf(15, 0, 15), // duplicate is dropped
)
val out = writer.writeCalendar(listOf(event), stamp)
val l = out.split(ICS_CRLF)
assertThat(l.count { it == "BEGIN:VALARM" }).isEqualTo(2)
assertThat(l).contains("TRIGGER:-PT15M")
assertThat(l).contains("TRIGGER:PT0M")
assertThat(l).contains("ACTION:DISPLAY")
}
@Test
fun `text fields and the calendar name are escaped`() {
val event = IcsEvent(
uid = "u6@calendula",
summary = "Lunch; with, notes",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 14, 0),
isAllDay = false,
zoneId = "UTC",
location = "Cafe\\Bar",
availability = Availability.Free,
status = EventStatus.Tentative,
calendarName = "Work, Personal",
)
val l = lines(listOf(event))
assertThat(l).contains("SUMMARY:Lunch\\; with\\, notes")
assertThat(l).contains("LOCATION:Cafe\\\\Bar")
assertThat(l).contains("STATUS:TENTATIVE")
assertThat(l).contains("TRANSP:TRANSPARENT")
assertThat(l).contains("X-CALENDULA-CALENDAR:Work\\, Personal")
}
@Test
fun `existing uid is kept and a missing one is synthesised stably`() {
assertThat(deriveIcsUid("real-uid@host", 7, 1000)).isEqualTo("real-uid@host")
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo("7-1000@calendula")
assertThat(deriveIcsUid(" ", 7, 1000)).isEqualTo("7-1000@calendula")
// Stable across calls — a re-export of the same row yields the same UID.
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
}
}

View File

@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
class ParsedIcsFormTest {
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `timed event maps to wall-clock form times in the device zone`() {
val event = ParsedIcsEvent(
uid = "u@x",
summary = "Call",
start = LocalDateTime(2026, 6, 18, 13, 0, 0).toInstant(TimeZone.UTC),
end = LocalDateTime(2026, 6, 18, 14, 0, 0).toInstant(TimeZone.UTC),
isAllDay = false,
zoneId = "UTC",
reminderMinutes = listOf(10, 10, 5),
recurrenceRule = "FREQ=WEEKLY",
)
val form = event.toEventForm(berlin)
assertThat(form.calendarId).isNull()
assertThat(form.title).isEqualTo("Call")
// 13:00 UTC == 15:00 Berlin (summer).
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
assertThat(form.reminders).containsExactly(5, 10).inOrder()
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `all-day event shows the last covered day, not the exclusive end`() {
val event = ParsedIcsEvent(
uid = null,
summary = "Trip",
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
end = LocalDate(2026, 6, 20).atStartOfDayIn(TimeZone.UTC), // exclusive
isAllDay = true,
zoneId = "UTC",
)
val form = event.toEventForm(berlin)
assertThat(form.isAllDay).isTrue()
assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 18))
assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 19)) // last covered day
}
}

View File

@@ -62,13 +62,28 @@ class WeekLayoutTest {
assertThat(ev.coversDay(wed, zone)).isTrue()
assertThat(ev.coversDay(mon, zone)).isFalse()
val multiDay = event(at(mon, 22), at(wed, 2), allDay = true)
// All-day: UTC midnights, end exclusive. Mon..Tue covers Mon and Tue
// but not Wed (the Wed-midnight end is exclusive).
val multiDay = event(at(mon, 0), at(wed, 0), allDay = true)
assertThat(multiDay.coversDay(mon, zone)).isTrue()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue()
assertThat(multiDay.coversDay(wed, zone)).isTrue()
assertThat(multiDay.coversDay(wed, zone)).isFalse()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse()
}
@Test
fun `single-day all-day event does not leak into the next day east of UTC`() {
// A birthday on Wed: the provider stores UTC midnights with an exclusive
// end (Thu 00:00 UTC). In a zone east of UTC the device-local day must
// still resolve to Wed only — never Thu. Regression for the all-day
// event appearing on two days in the views.
val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in June
val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
assertThat(ev.coversDay(wed, berlin)).isTrue()
assertThat(ev.coversDay(wed.plusDays(1), berlin)).isFalse()
assertThat(ev.coversDay(wed.plusDays(-1), berlin)).isFalse()
}
@Test
fun `single timed event gets one lane`() {
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)

View File

@@ -0,0 +1,150 @@
# Calendula - Plan 05: ICS Export (v2.7, Branch 1 von 2)
> **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:** Die Schreib-Hälfte des `.ics`-Themas. Calendula serialisiert eigene
Events nach RFC 5545 — als Einzel-Event (Share-Sheet) und als
Ganz-Kalender-Backup (SAF-Datei). Damit existiert für gerätelokale Kalender
(`ACCOUNT_TYPE_LOCAL`) zum ersten Mal ein Backup; ohne Sync ist ein verlorenes
Gerät sonst Totalverlust. Diese Branch baut **nur Export**; Import
(Parser, Restore, Open-into-form) folgt in Branch 2 (`feat/ics-import`),
beide landen zusammen in **einem** Release v2.7.0 — es gibt also keine
Zwischenversion, die UIDs schreibt, ohne sie je zu lesen.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review (gemeinsam mit Branch 2).
**Architecture:** Neue reine-Kotlin-Engine `domain/ics/` — kein
`CalendarContract`, keine Android-Deps, voll JVM-testbar. Kern ist
`IcsWriter` (nimmt eine Liste eigener Event-Modelle + Kalender-Metadaten,
gibt einen `VCALENDAR`-String zurück). Keine ICS-Library: wir bleiben auf
`kotlinx-datetime` (kein `java.time`-Desugaring) und hand-rollen wie schon
bei RRULE in `Recurrence.kt`. Das Schreiben in eine SAF-Datei / das
Share-Intent liegt in einer dünnen Android-Schicht
(`data/ics/IcsExporter` o. ä.), die Provider-Reads → Domain-Modelle →
`IcsWriter``OutputStream` verdrahtet.
**Recherche-Befunde (Codebase, 2026-06-18):**
1. **Keine ICS-Library, kein `java.time`-Desugaring** — Stack ist
`kotlinx-datetime` + `kotlin.time.Instant`. RRULE wird bereits in
`domain/Recurrence.kt` von Hand geparst/gerendert (inkl. der sorgfältigen
`UNTIL`/DST-Korrektur). `IcsWriter` reiht sich in genau diese Kultur ein und
nutzt `SimpleRecurrence.toRRule()` direkt.
2. **Kein UID-Handling.** `Events.UID_2445` wird heute nirgends gelesen oder
geschrieben. Für idempotenten Restore in Branch 2 muss Import auf UID
matchen — ohne UID verdoppelt ein erneuter Import alles. Darum schreibt
**diese** Branch bereits UID bei jedem Insert (Vorarbeit; ohne Import noch
unsichtbar, zahlt sich erst in Branch 2 aus — beide im selben Release).
3. **Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt.** Lesen/Anzeige:
`EVENT_TIMEZONE` landet in `EventDetail.eventTimezone`, das Detail zeigt ein
Fremdzonen-Label nur bei Abweichung vom Gerät (`foreignTimeZoneLabel`).
Schreiben (`EventWriteMapper.toWriteTimes`): all-day → UTC-Mitternachten,
`EVENT_TIMEZONE="UTC"`; getimt → `EVENT_TIMEZONE = zone.id`, und alle Caller
übergeben `currentSystemDefault()` (kein Zonen-Feld im Formular). Jedes selbst
erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte
Events behalten ihre Originalzone.
**Leitentscheidungen:**
1. **Zeitzonen-Regel beim Schreiben (fallbasiert):**
- **All-day** → `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND` exklusiv
(Tag-danach). Keine Zone — trivial korrekt.
- **Getimt, nicht wiederkehrend** → UTC-Instant `…T…Z`. Ein Instant ist ein
Instant; die Anzeige rechnet beim Import wieder in die Gerätezone. Verlustfrei.
- **Getimt, wiederkehrend** → `DTSTART;TZID=<EVENT_TIMEZONE>:<lokale Wandzeit>`.
Eine Serie muss an der **Wandzeit** verankert sein, sonst driftet ein
„wöchentlich 9 Uhr" über die nächste DST-Grenze um eine Stunde. Die Zone
liegt bereits in `EVENT_TIMEZONE` vor; die lokale Wandzeit ist eine
`kotlinx-datetime`-Konversion (Instant → LocalDateTime in der Zone).
- **`VTIMEZONE`-Blöcke werden bewusst NICHT emittiert.** Beim eigenen
Round-Trip löst Branch-2-Import `TZID` gegen die OS-tz-Datenbank auf
(`kotlinx-datetime`/`java.time` kennen jede IANA-Id). Technisch nicht
RFC-konform; einziger Preis sind strenge Fremd-Parser ohne tz-DB — als
bekannte Lücke dokumentiert (Skip-and-report-Territorium des Imports),
kein Blocker. Voll-`VTIMEZONE` ist „später, falls nötig".
2. **UID bei jedem Insert.** `insertEvent` schreibt fortan `Events.UID_2445`
(z. B. `<random-uuid>@calendula`). Bestehende Events ohne UID exportieren
wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus
`event-id + DTSTART` (`<id>-<dtstart>@calendula`), damit derselbe Bestand
über mehrere Backups dieselbe UID behält und Branch-2-Restore nicht
verdoppelt. Bestehende Rows werden **nicht** rückwirkend gestempelt
(kein Migrations-Sweep über fremde Kalender).
3. **Manueller Export, kein Background.** Backup via
`ACTION_CREATE_DOCUMENT` (SAF, MIME `text/calendar`, Default-Name
`calendula-backup-<datum>.ics`); Einzel-Event-Share via `ACTION_SEND` mit
einem `FileProvider`-Cache-File (`text/calendar`). Kein WorkManager, kein
geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos;
Auto-Backup bleibt explizit Roadmap-`later`).
4. **Backup-Layout: eine kombinierte `VCALENDAR`-Datei** über alle
gerätelokalen (beschreibbaren) Kalender. Pro Event ein `VEVENT`; die
Kalender-Zugehörigkeit reist als `X-WR-CALNAME` / `CATEGORIES` o. ä. mit,
damit Branch-2-Restore wieder auffächern oder per Ziel-Picker einsortieren
kann. Eine Datei ist einfacher zu teilen/abzulegen als n Dateien.
*Offen, vor dem Backup-Task zu fixieren:* exaktes Property fürs
Kalender-Mapping (`X-WR-CALNAME` pro `VCALENDAR` erlaubt nur einen Namen;
für mehrere Kalender in einer Datei brauchen wir ein Pro-`VEVENT`-Property
wie `X-CALENDULA-CALENDAR` oder `CATEGORIES`).
5. **Feldumfang = was Calendula modelliert.** `IcsWriter` serialisiert genau
die gelesenen Felder: `SUMMARY`, `DTSTART`/`DTEND` (Regel #1),
`LOCATION`, `DESCRIPTION`, `RRULE` (über `toRRule`), `VALARM` aus den
Remindern (DISPLAY, `TRIGGER` = `-PT<min>M`), `STATUS`
(CONFIRMED/TENTATIVE/CANCELLED), `TRANSP` (Free→TRANSPARENT/Busy→OPAQUE),
`UID`, `DTSTAMP`. Felder ohne sauberes Modell (Attendees, RECURRENCE-ID-
Ausnahmen) bleiben **vorerst weg** — Export erzeugt nichts, was Import in
Branch 2 nicht auch wieder lesen kann.
6. **Korrekte RFC-5545-Mechanik:** Zeilen-Folding bei >75 Oktett (CRLF +
Space-Fortsetzung), Text-Escaping (`\` `;` `,` `\n`), CRLF-Zeilenenden,
`PRODID`/`VERSION:2.0`-Header. Eine reine, einzeln getestete Hilfsschicht
(`IcsLine`/`fold`/`escapeText`), nicht ad hoc im Writer verstreut.
---
## Tasks
**Domain-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [x] `IcsText`: `escapeText`, `foldLine` (75-Oktett, CRLF+Space) + Test
(`IcsTextTest`). Wert-Helfer (Instant→`…T…Z`, Wandzeit→`TZID`,
LocalDate→`VALUE=DATE`) leben als private Helfer in `IcsWriter`.
- [x] `IcsEvent`-Eingabemodell (reine Kotlin: summary, start/end als Instant
+ isAllDay + zoneId, recurrenceRule?, location, description,
reminderMinutes, status, availability, uid, calendarName) — entkoppelt
vom Provider-Modell
- [x] `IcsWriter.writeCalendar(events, dtStamp)` → String: Header, pro Event
`VEVENT` nach Entscheidung #5, Zeitzonen-Regel #1, `VALARM`; JVM-Test
`IcsWriterTest` (all-day, getimt, wiederkehrend+TZID, unbekannte Zone,
Reminder, Escaping)
- [x] UID-Ableitung `deriveIcsUid` (`uid ?: "<eventId>-<dtstartMillis>@calendula"`)
+ Stabilitätstest
**Provider → Domain (`data/calendar/IcsExportMapper.kt`):**
- [x] Mapper Provider-Row → `IcsEvent` (`ColumnReader.toIcsEvent`) inkl.
DURATION→DTEND-Rekonstruktion (`parseRfc2445DurationMillis`),
`EventExportProjection`; Datasource-Methode `exportableEvents()` +
Repository `exportEvents()`; Test `IcsExportMapperTest`
- [x] `insertEvent` schreibt `Events.UID_2445` (`UUID@calendula`) bei jedem
Create
**Android-Export-Schicht:**
- [x] `data/ics/IcsExporter`: `writeDocument(uri)` (SAF) + `stageShareFile`
(FileProvider-Cache) als UTF-8
- [x] Einzel-Event-Share: Share-Action im Event-Detail → `IcsWriter` für ein
Event (one-off) → Cache-File über `FileProvider``ACTION_SEND`
- [x] Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars →
`ACTION_CREATE_DOCUMENT` → in den URI streamen; Ergebnis-Snackbar
(Plural „Exported N events")
- [x] `FileProvider` + `file_paths.xml` im Manifest (Cache-Dir für Shares)
- [x] Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/
Fehler + Plural, dateierter Default-Name
**Abschluss:**
- [ ] `./gradlew lint test assembleDebug` grün ← **nächster Schritt (Test)**
- [x] CHANGELOG (`[Unreleased]`) ergänzt
- [ ] On-Device-Review; **kein** Tag/Release vor Review und vor Merge von
Branch 2 (`feat/ics-import`)
**Offene Detail-Calls (vor Review klären, nicht-blockierend):**
- Kalender→Event-Mapping nutzt das per-`VEVENT`-Property `X-CALENDULA-CALENDAR`
(statt `X-WR-CALNAME`), damit eine kombinierte Datei mehrere Kalender trägt.
- Backup = **eine** kombinierte `VCALENDAR`-Datei über alle lokalen Kalender.
- EXDATE / `RECURRENCE-ID`-Ausnahmen werden beim Export ausgelassen
(`ORIGINAL_ID IS NULL`) — dokumentierter v1-Grenzfall, Import lässt sie auch aus.

View File

@@ -0,0 +1,122 @@
# Calendula - Plan 06: ICS Import (v2.7, Branch 2 von 2)
> **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:** Die Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
Kalender. Beide Branches landen in **einem** Release v2.7.0.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter`
kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
`IcsParseResult` (`events: List<ParsedIcsEvent>` + `warnings: List<String>`).
`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
Repository-Methode `importEvents(targetCalendarId, events)``ImportSummary`
(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
Event-Anzahl.
**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
den Rest der Datei durch.
**Leitentscheidungen:**
1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
Schicht (`IcsLineParser`), nicht ad hoc im Walker.
2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
- `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
- `…T…Z` → UTC-Instant.
- `…T…` mit `TZID=<zone>` → lokale Wandzeit in der Zone, aufgelöst gegen die
**OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID`
Gerätezone als Fallback (+ Warnung).
- Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
seine `TZID` nicht in der OS-DB ist).
3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null`
Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
Datei → freundlicher „nichts gefunden"-Hinweis.
4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
`Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
skip-not-update — kein Überschreiben, das hält den Restore idempotent und
verlustfrei. Events ohne UID bekommen beim Insert eine frische
(`UUID@calendula`, wie `insertEvent`).
5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
(+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
(`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
Notification-Deep-Link).
6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
`PT0…`) → Lead-Minuten; `STATUS`/`TRANSP``EventStatus`/`Availability`.
`DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
vorhanden).
**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `P<n>S`.
- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
**vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
---
## Tasks
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
gequotete Params)
- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
- [x] `IcsParser.parse(text)``IcsParseResult(events, warnings)`: VCALENDAR/
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
**Datenschicht (`data/calendar/` + `data/ics/`):**
- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
- [x] Repository `importEvents(targetCalendarId, events)``ImportSummary`
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
Fake-Datasource
- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
**Intent + Routing:**
- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
>1 → Bulk-Import-Screen; 0 → Hinweis
**UI:**
- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
leere-Datei-Hinweis
**Abschluss:**
- [x] `./gradlew lint test assembleDebug` grün
- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
Branches gemerged sind und On-Device-Review durch ist

56
renovate.json5 Normal file
View File

@@ -0,0 +1,56 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: [
"config:recommended",
// chore(deps): … — match the repo's conventional-commit style.
":semanticCommits",
],
// No automerge: a dependency bump goes through the same review (and, for
// anything touching the build, the same on-device check) as a feature
// before it can ride a release — see docs/RELEASING.md and the
// "hold release for approval" rule.
automerge: false,
// One reviewable surface; the dashboard issue lists everything pending.
dependencyDashboard: true,
labels: ["dependencies"],
prConcurrentLimit: 5,
prHourlyLimit: 0,
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
// Mondays) — no internal `schedule` here, so the two don't double-gate and
// silently skip a run.
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
// the github-actions manager (same syntax) to watch them too.
"github-actions": {
fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"],
},
packageRules: [
// material3 is deliberately pinned to the 1.5 *alpha* line for the
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
// but keep it in its own PR, reviewed in isolation; revisit the pin when
// 1.5.0 stable lands.
{
matchPackageNames: ["androidx.compose.material3:material3"],
ignoreUnstable: false,
groupName: "material3 (alpha)",
},
// Test-only deps: group into one low-noise PR.
{
matchPackageNames: [
"org.junit.jupiter:**",
"org.junit.platform:**",
"com.google.truth:**",
"app.cash.turbine:**",
"androidx.test:**",
"androidx.test.espresso:**",
"androidx.test.ext:**",
],
groupName: "test dependencies",
},
],
}

94
scripts/check_translations.py Executable file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Validate Android translation resources against the base strings.xml.
Community translations live in ``app/src/main/res/values-<locale>/strings.xml``
and are produced via Weblate. This guard keeps incoming translation PRs honest:
* every translation file must be well-formed XML;
* a translation must not define keys absent from the base — those are stale
keys left behind after a rename/removal upstream;
* a translation must not translate strings marked ``translatable="false"`` in
the base (URLs, IDs and the like).
Missing keys are *allowed* and only reported as coverage: a missing string
falls back to the English base at runtime, so partial translations are fine
(this mirrors the lint config, which downgrades ``MissingTranslation``).
Exits non-zero if any error is found. Errors are emitted as Gitea/GitHub
Actions ``::error`` annotations so they surface inline on the PR.
"""
from __future__ import annotations
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
RES_DIR = Path("app/src/main/res")
BASE = RES_DIR / "values" / "strings.xml"
RESOURCE_TAGS = ("string", "plurals", "string-array")
def entries(path: Path) -> dict[str, bool]:
"""Map resource name -> is-translatable for every entry in ``path``."""
root = ET.parse(path).getroot()
return {
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
for el in root
if el.tag in RESOURCE_TAGS and "name" in el.attrib
}
def main() -> int:
if not BASE.exists():
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
return 1
base = entries(BASE)
base_keys = set(base)
nontranslatable = {name for name, ok in base.items() if not ok}
translatable_total = len(base_keys - nontranslatable)
files = sorted(RES_DIR.glob("values-*/strings.xml"))
if not files:
print("No translation files found (values-*/strings.xml).")
return 0
errors = 0
for path in files:
locale = path.parent.name[len("values-"):]
try:
translated = entries(path)
except ET.ParseError as exc:
print(f"::error file={path}::{locale}: malformed XML: {exc}")
errors += 1
continue
keys = set(translated)
stale = sorted(keys - base_keys)
translated_fixed = sorted(keys & nontranslatable)
missing = base_keys - nontranslatable - keys
for name in stale:
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
errors += 1
for name in translated_fixed:
print(
f"::error file={path}::{locale}: key '{name}' is translatable=\"false\" "
"in the base and must not be translated"
)
errors += 1
covered = translatable_total - len(missing)
pct = covered * 100 // translatable_total if translatable_total else 100
verdict = "OK" if not (stale or translated_fixed) else "FAIL"
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
if errors:
print(f"\n{errors} translation error(s) found.", file=sys.stderr)
return 1
print("\nAll translation files are consistent with the base.")
return 0
if __name__ == "__main__":
sys.exit(main())