Compare commits
19 Commits
v2.5.0
...
feat/crash
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ab3344f8c | |||
| 2431abe912 | |||
| 701077f25b | |||
| 290a905f8b | |||
| d20d446cbe | |||
| 6e14d5964b | |||
| 3dfc96718c | |||
| e1c2e9f2e5 | |||
| 90b219bdad | |||
| 233a9b03a3 | |||
| 0b683d374f | |||
| 64d0a89b28 | |||
| 7285e274df | |||
| 788ca3906e | |||
| bab6fd175a | |||
| 3d5cc55ef1 | |||
| 111b3782b0 | |||
| cf380b6eab | |||
| 9177a926df |
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
23
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something doesn't work the way it should
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
---
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### What you expected
|
||||||
|
|
||||||
|
|
||||||
|
### Steps to reproduce
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
26
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
26
.gitea/ISSUE_TEMPLATE/crash_report.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Crash report
|
||||||
|
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
||||||
|
title: "Crash: "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- crash
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Thanks for reporting a crash in Calendula!
|
||||||
|
|
||||||
|
If the app prefilled this for you, the crash report is already below — just add
|
||||||
|
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||||
|
into the code block. The report contains only app/Android/device versions and the
|
||||||
|
stack trace — no personal data or calendar content.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### Crash report
|
||||||
|
|
||||||
|
```
|
||||||
|
(paste the crash report here)
|
||||||
|
```
|
||||||
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
16
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea or improvement
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
### What would you like Calendula to do?
|
||||||
|
|
||||||
|
|
||||||
|
### Why — what problem does it solve?
|
||||||
|
|
||||||
|
|
||||||
|
### Anything else
|
||||||
|
<!-- mockups, examples from other apps, alternatives you considered -->
|
||||||
41
.gitea/workflows/translations.yaml
Normal file
41
.gitea/workflows/translations.yaml
Normal 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
|
||||||
@@ -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)*
|
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
|
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
||||||
|
|
||||||
**Tier 4 — interop & bigger-ticket**
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
9. Share event as .ics + receive/open .ics into a prefilled create form
|
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||||
10. Default reminder applied to new events; then snooze/dismiss notification actions
|
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||||
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||||
|
10. **The `.ics` engine — export + import** *(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)**
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||||
@@ -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,
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
|
Debatable calls worth a second look: whether **local-calendar backup (#10)**
|
||||||
whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
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
|
## 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
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
natural data source for a future widget)
|
natural data source for a future widget)
|
||||||
- Jump to date — drawer date picker (un-cut from V1)
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
|
- Current-time "now" line in day/week — standard in every calendar, cheap,
|
||||||
|
currently absent. Daily-driver polish.
|
||||||
|
- Week numbers in the **month** grid — week view already shows the badge
|
||||||
|
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
||||||
- Pinch-to-zoom time scale in day/week
|
- Pinch-to-zoom time scale in day/week
|
||||||
- Tablet / foldable layouts *(was v3.0)*
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
- Full-text search *(was v3.0)*
|
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
|
||||||
|
with real event history, finding an event is core completeness, not optional.
|
||||||
|
|
||||||
## Event editing & creation
|
## Event editing & creation
|
||||||
|
|
||||||
@@ -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.
|
go/no-go gate as the OSM/INTERNET item below.
|
||||||
- Move event to another calendar (copy+delete model with a consequences
|
- Move event to another calendar (copy+delete model with a consequences
|
||||||
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||||
|
- **Local-calendar backup / export** *(Tier 4 #10)* — 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 31–32 (user-revocable → settings deep-link prompt).
|
||||||
|
- **Battery-optimization exemption:** a *soft, optional* prompt via
|
||||||
|
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
|
||||||
|
auto-grant intent), honest copy: "Android may delay reminders to save battery;
|
||||||
|
exempt Calendula for on-time delivery." Shown once after the existing
|
||||||
|
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
|
||||||
|
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
|
||||||
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
|
are notorious for suppressing it).
|
||||||
|
|
||||||
|
### Open decisions (resolve before building)
|
||||||
|
|
||||||
|
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
||||||
|
(reliability vs simplicity + battery cost).
|
||||||
|
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
||||||
|
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
||||||
|
a list inside the Notifications sub-page).
|
||||||
|
|
||||||
|
### Later (round two)
|
||||||
|
|
||||||
- Snooze + dismiss actions on the notification (snooze needs an
|
- Snooze + dismiss actions on the notification (snooze needs an
|
||||||
exact-alarm / WorkManager decision)
|
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||||
- Settings default reminder applied to new events
|
|
||||||
|
|
||||||
## Sharing & interop
|
## Sharing & interop
|
||||||
|
|
||||||
@@ -312,8 +425,18 @@ whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
|||||||
|
|
||||||
## Platform & launchers
|
## Platform & launchers
|
||||||
|
|
||||||
- Home-screen widget *(was v3.0)*
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
||||||
|
optional quick-settings tile still open
|
||||||
|
|
||||||
|
## Quality & reliability
|
||||||
|
|
||||||
|
- **Accessibility pass** — TalkBack content descriptions across all screens,
|
||||||
|
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
|
||||||
|
F-Droid app; nothing tracks it yet.
|
||||||
|
- **Reminder delivery reliability** — exact alarms + battery-optimization
|
||||||
|
exemption; specced in the "Reminders — defaults & delivery reliability" slice
|
||||||
|
above (Tier 4 #9).
|
||||||
|
|
||||||
## Locations & People *(go/no-go, captured 2026-06-11)*
|
## Locations & People *(go/no-go, captured 2026-06-11)*
|
||||||
|
|
||||||
|
|||||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.7.1] — 2026-06-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Crash reporting you control. If Calendula closes unexpectedly, it now captures
|
||||||
|
a technical report and, on the next launch, offers to send it as an issue on
|
||||||
|
the project's tracker. Nothing is uploaded automatically — the report stays on
|
||||||
|
your device until you choose to share it, it contains no personal data or
|
||||||
|
calendar content (only the app, Android and device versions plus the stack
|
||||||
|
trace), and you see the full text before sending. There's also a "Report a
|
||||||
|
problem" entry in Settings, and if the app ever fails to start repeatedly, a
|
||||||
|
minimal recovery screen still lets you send the report.
|
||||||
|
|
||||||
|
## [2.7.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
|
## [2.5.0] — 2026-06-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// (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.
|
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||||
versionCode = 20500
|
versionCode = 20701
|
||||||
versionName = "2.5.0"
|
versionName = "2.7.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
testOptions {
|
||||||
unitTests {
|
unitTests {
|
||||||
all { it.useJUnitPlatform() }
|
all { it.useJUnitPlatform() }
|
||||||
|
|||||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
@@ -4,3 +4,14 @@
|
|||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|
||||||
|
# Room database implementations (pulled in transitively via
|
||||||
|
# androidx.glance:glance-appwidget → androidx.work → androidx.room).
|
||||||
|
# The widgets rely on Glance, whose WorkManager backend stores state in a Room
|
||||||
|
# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
|
||||||
|
# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
|
||||||
|
# so Room's reflective instantiation throws InstantiationException and the app
|
||||||
|
# crashes at startup with "Failed to create an instance of ...WorkDatabase".
|
||||||
|
# Keep the generated Room database implementations fully intact.
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
|
delayed by Doze). Used only to launch that dialog; falls back to the
|
||||||
|
battery-optimisation list if the OS declines the direct intent.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
@@ -25,6 +32,7 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Calendula"
|
android:theme="@style/Theme.Calendula"
|
||||||
@@ -39,12 +47,36 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||||
|
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Receive a .ics shared from another app. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||||
|
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||||
|
graph and Compose content. Not exported: launched only by us. -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.crash.CrashReportActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
@@ -104,6 +136,19 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Hands .ics files we stage in the cache to other apps via a content
|
||||||
|
Uri (single-event share). Authority tracks applicationId so the
|
||||||
|
debug suffix doesn't break getUriForFile. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||||
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class CalendulaApp : Application()
|
class CalendulaApp : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
|
// respecting, on-device; the user submits the report by hand).
|
||||||
|
CrashReporter.install(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,29 +2,35 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
@@ -35,11 +41,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||||
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||||
|
|
||||||
|
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
|
||||||
|
// by CalendarHost's import flow.
|
||||||
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
|
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// If the app keeps crashing as it starts, the main UI can't be trusted
|
||||||
|
// to come up — route to the standalone report screen instead of
|
||||||
|
// re-entering the crashing graph.
|
||||||
|
if (CrashReporter.isCrashLoop(this)) {
|
||||||
|
startActivity(
|
||||||
|
Intent(this, CrashReportActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
@@ -60,15 +90,54 @@ class MainActivity : ComponentActivity() {
|
|||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
widgetNavRequest = requestedNav,
|
widgetNavRequest = requestedNav,
|
||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = { requestedImportUri = null },
|
||||||
)
|
)
|
||||||
|
pendingCrashReport?.let { report ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this@MainActivity, report)
|
||||||
|
CrashReporter.clearReport(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
// Keep the report (Settings can still reach it); just
|
||||||
|
// stop it popping on every launch.
|
||||||
|
CrashReporter.dismissPrompt(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.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 {
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -18,10 +18,15 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -47,6 +52,26 @@ interface CalendarDataSource {
|
|||||||
*/
|
*/
|
||||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
|
* rows are excluded (see [EventExportProjection]).
|
||||||
|
*/
|
||||||
|
fun exportableEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
|
* dedup an `.ics` import so re-importing a backup doesn't double events.
|
||||||
|
*/
|
||||||
|
fun existingUids(calendarId: Long): Set<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
|
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||||
|
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||||
|
*/
|
||||||
|
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||||
@@ -60,24 +85,40 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
/** Insert a new event; returns the new `Events._ID`. */
|
/**
|
||||||
fun insertEvent(form: EventForm): Long
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
|
* should fire at — encoded into each all-day reminder's provider offset
|
||||||
|
* (ignored for timed events).
|
||||||
|
*/
|
||||||
|
fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing event (for recurring events: the whole series) to
|
* Update an existing event (for recurring events: the whole series) to
|
||||||
* match [updated]. [original] is the form as it was prefilled from the
|
* match [updated]. [original] is the form as it was prefilled from the
|
||||||
* event, so only fields the user actually changed are written and the
|
* event, so only fields the user actually changed are written and the
|
||||||
* reminder rows can be diffed instead of wiped.
|
* reminder rows can be diffed instead of wiped.
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
* row's `Events._ID`.
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a recurring event from the occurrence at [beginMillis] onwards
|
* Change a recurring event from the occurrence at [beginMillis] onwards
|
||||||
@@ -92,6 +133,7 @@ interface CalendarDataSource {
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,7 +204,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
putDescription(description)
|
putDescription(description)
|
||||||
}
|
}
|
||||||
val uri = resolver.insert(localCalendarsUri(), values)
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
?: throw WriteFailedException("create local calendar '$name'")
|
// No calendar name in the message — it can reach a crash report.
|
||||||
|
?: throw WriteFailedException("create local calendar")
|
||||||
return ContentUris.parseId(uri)
|
return ContentUris.parseId(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +290,112 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun exportableEvents(): List<IcsEvent> {
|
||||||
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
|
// already have a backup (their server). Map id → display name for the
|
||||||
|
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||||
|
val names = calendars()
|
||||||
|
.filter { it.isLocal && it.canModifyContents }
|
||||||
|
.associate { it.id to it.displayName }
|
||||||
|
if (names.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val idList = names.keys.joinToString(",")
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
EventExportProjection.COLUMNS,
|
||||||
|
// Skip soft-deleted rows and exception rows (modified occurrences /
|
||||||
|
// cancellations) — v1 exports masters + one-offs only.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
|
||||||
|
null,
|
||||||
|
CalendarContract.Events.DTSTART + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll {
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
||||||
|
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||||
|
reader.toIcsEvent(
|
||||||
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
|
calendarName = names[calendarId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||||
|
arrayOf(calendarId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
|
} ?: emptySet()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||||
|
val startMillis = event.start.toEpochMillis()
|
||||||
|
val endMillis = event.end.toEpochMillis()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
|
// only when the source event carried none.
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, startMillis)
|
||||||
|
if (event.recurrenceRule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// timed rows keep the event's own zone.
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
|
val eventId = ContentUris.parseId(uri)
|
||||||
|
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||||
|
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
@@ -265,13 +414,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
private data class CalendarAccount(val name: String, val type: String)
|
private data class CalendarAccount(val name: String, val type: String)
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
/**
|
||||||
|
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
|
||||||
|
* all-day reminder is shifted to fire at [allDayReminderTimeMinutes] local
|
||||||
|
* (see [toProviderAllDayMinutes]); a timed reminder is its lead time as-is.
|
||||||
|
*/
|
||||||
|
private fun providerReminderMinutes(
|
||||||
|
form: EventForm,
|
||||||
|
minutes: Int,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Int = if (form.isAllDay) {
|
||||||
|
toProviderAllDayMinutes(
|
||||||
|
semanticMinutes = minutes,
|
||||||
|
startDate = form.start.date.toJavaLocalDate(),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [form]'s reminders as the distinct raw provider offsets to store. */
|
||||||
|
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
|
||||||
|
form.reminders
|
||||||
|
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(
|
put(
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
)
|
)
|
||||||
|
// A globally-unique UID so a later .ics backup/restore can identify
|
||||||
|
// the event and not duplicate it on re-import (the provider leaves
|
||||||
|
// this null for events it didn't sync). Older rows without one fall
|
||||||
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
|
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
@@ -303,20 +483,26 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
// Best effort (spec §8): the event exists at this point — a reminder
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
// that fails to attach is logged, not surfaced as a failed create.
|
||||||
form.reminders.distinct().forEach { minutes ->
|
encodedReminders(form, allDayReminderTimeMinutes)
|
||||||
val reminder = ContentValues().apply {
|
.forEach { minutes ->
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
override fun updateEvent(
|
||||||
|
eventId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
) {
|
||||||
val values = buildEventUpdateValues(
|
val values = buildEventUpdateValues(
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
@@ -332,13 +518,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||||
}
|
}
|
||||||
// Untouched reminder sets are left alone so unrelated edits can't
|
// Untouched reminder sets are left alone so unrelated edits can't
|
||||||
// disturb provider rows the form never knew about.
|
// disturb provider rows the form never knew about. The diff is on the
|
||||||
|
// form's semantic minutes; reconcile works in encoded provider minutes.
|
||||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
reconcileReminders(eventId, updated.reminders)
|
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
override fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -352,7 +544,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val exceptionId = ContentUris.parseId(uri)
|
val exceptionId = ContentUris.parseId(uri)
|
||||||
// Whether the provider copied the parent's reminder rows is its
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
// business — reconciling against the actual rows handles both ways.
|
// business — reconciling against the actual rows handles both ways.
|
||||||
reconcileReminders(exceptionId, form.reminders)
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,16 +553,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// From the first occurrence on (or with no rule to split) this is
|
// From the first occurrence on (or with no rule to split) this is
|
||||||
// just a series update.
|
// just a series update.
|
||||||
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
|
||||||
updateEvent(eventId, original, updated)
|
updateEvent(eventId, original, updated, allDayReminderTimeMinutes)
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
// Insert the new series first: if it fails, the original is untouched.
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
val newEventId = insertEvent(updated)
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
truncateSeries(eventId, row, beginMillis)
|
truncateSeries(eventId, row, beginMillis)
|
||||||
return newEventId
|
return newEventId
|
||||||
}
|
}
|
||||||
@@ -456,9 +649,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the event's reminder rows match [targetMinutes]: rows with other
|
* Make the event's reminder rows match [targetMinutes] — the raw provider
|
||||||
* lead times are deleted, missing ones inserted as best-effort ALERTs
|
* offsets to store (already encoded via [encodedReminders], so all-day shifts
|
||||||
* (like insertEvent). Rows whose minutes survive keep their method.
|
* are baked in and the diff matches the stored rows). Rows with other offsets
|
||||||
|
* are deleted, missing ones inserted as best-effort ALERTs (like insertEvent).
|
||||||
|
* Rows whose minutes survive keep their method.
|
||||||
*/
|
*/
|
||||||
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
|
||||||
val target = targetMinutes.toSet()
|
val target = targetMinutes.toSet()
|
||||||
@@ -491,7 +686,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
is String -> cv.put(column, value)
|
is String -> cv.put(column, value)
|
||||||
is Long -> cv.put(column, value)
|
is Long -> cv.put(column, value)
|
||||||
is Int -> cv.put(column, value)
|
is Int -> cv.put(column, value)
|
||||||
else -> error("Unsupported value for $column: $value")
|
// Only the type, never the value — a cell value can be event content.
|
||||||
|
else -> error("Unsupported value type for column '$column': ${value::class.simpleName}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -28,6 +31,19 @@ interface CalendarRepository {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
suspend fun deleteCalendar(id: Long)
|
suspend fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
*/
|
||||||
|
suspend fun exportEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||||
|
* UID already exists in the target are skipped (idempotent restore); the
|
||||||
|
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
|
||||||
|
*/
|
||||||
|
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||||
|
|
||||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
suspend fun createEvent(form: EventForm): Long
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
@@ -28,9 +32,14 @@ import javax.inject.Singleton
|
|||||||
class CalendarRepositoryImpl @Inject constructor(
|
class CalendarRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: CalendarDataSource,
|
private val dataSource: CalendarDataSource,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : CalendarRepository {
|
) : CalendarRepository {
|
||||||
|
|
||||||
|
/** The configured wall-clock fire time for all-day reminders, read per write. */
|
||||||
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
@@ -92,8 +101,30 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
|
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||||
|
|
||||||
|
override suspend fun importEvents(
|
||||||
|
targetCalendarId: Long,
|
||||||
|
events: List<ParsedIcsEvent>,
|
||||||
|
): IcsImportSummary = withContext(io) {
|
||||||
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
|
var imported = 0
|
||||||
|
var skipped = 0
|
||||||
|
for (event in events) {
|
||||||
|
// A known UID means the event is already in this calendar — skip,
|
||||||
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
|
if (event.uid != null && event.uid in existing) {
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
dataSource.insertEvent(form)
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEvent(
|
override suspend fun updateEvent(
|
||||||
@@ -101,7 +132,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
dataSource.updateEvent(eventId, original, updated)
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
@@ -113,7 +144,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateOccurrence(eventId, beginMillis, form)
|
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEventFromOccurrence(
|
override suspend fun updateEventFromOccurrence(
|
||||||
@@ -122,7 +153,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
|
dataSource.updateEventFromOccurrence(
|
||||||
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
|
val isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
instanceId = eventId,
|
instanceId = eventId,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
@@ -65,11 +69,23 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
title = title,
|
title = title,
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
|
isAllDay = isAllDay,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// All-day reminders are stored as a wall-clock-shifted offset (see
|
||||||
|
// AllDayReminderEncoding); decode back to the whole-day lead time the form
|
||||||
|
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
||||||
|
// event's date is its UTC date.
|
||||||
|
val displayReminders = if (isAllDay) {
|
||||||
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||||
|
} else {
|
||||||
|
reminders
|
||||||
|
}
|
||||||
|
|
||||||
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
@@ -84,7 +100,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
reminders = reminders,
|
reminders = displayReminders,
|
||||||
status = status,
|
status = status,
|
||||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
// default these mappers already return — no isNull guard needed.
|
// default these mappers already return — no isNull guard needed.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
|
* for backup. [reminderMinutes] are the row's raw provider reminder offsets and
|
||||||
|
* [calendarName] the display name of its calendar (emitted as
|
||||||
|
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||||
|
* MapColumnReader.
|
||||||
|
*/
|
||||||
|
internal fun ColumnReader.toIcsEvent(
|
||||||
|
reminderMinutes: List<Int>,
|
||||||
|
calendarName: String?,
|
||||||
|
): IcsEvent {
|
||||||
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
|
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||||
|
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||||
|
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||||
|
val end = when {
|
||||||
|
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
|
||||||
|
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
|
||||||
|
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
||||||
|
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||||
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||||
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
|
?: "UTC",
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
|
reminderMinutes = reminderMinutes,
|
||||||
|
status = status,
|
||||||
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -97,6 +97,48 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||||
|
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||||
|
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||||
|
* occurrence and cancelled-exception rows are filtered out by the query
|
||||||
|
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||||
|
* exported yet — a documented v1 limit (import skips them too).
|
||||||
|
*/
|
||||||
|
internal object EventExportProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_UID = 1
|
||||||
|
const val IDX_TITLE = 2
|
||||||
|
const val IDX_DTSTART = 3
|
||||||
|
const val IDX_DTEND = 4
|
||||||
|
const val IDX_DURATION = 5
|
||||||
|
const val IDX_ALL_DAY = 6
|
||||||
|
const val IDX_EVENT_TIMEZONE = 7
|
||||||
|
const val IDX_RRULE = 8
|
||||||
|
const val IDX_LOCATION = 9
|
||||||
|
const val IDX_DESCRIPTION = 10
|
||||||
|
const val IDX_STATUS = 11
|
||||||
|
const val IDX_AVAILABILITY = 12
|
||||||
|
const val IDX_CALENDAR_ID = 13
|
||||||
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.crash
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
|
import java.io.File
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||||
|
* exception it writes a self-contained report to the app's private storage and
|
||||||
|
* then chains to the platform's default handler, so the process still dies
|
||||||
|
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||||
|
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||||
|
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||||
|
*
|
||||||
|
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||||
|
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||||
|
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||||
|
* is always shown the full text before it leaves the device.
|
||||||
|
*/
|
||||||
|
object CrashReporter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||||
|
* also catches crashes during startup. The handler swallows nothing — it
|
||||||
|
* persists, then delegates to the previously-registered handler.
|
||||||
|
*/
|
||||||
|
fun install(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
// Capturing must never mask the original crash, so guard every step.
|
||||||
|
runCatching {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||||
|
recordCrashTime(appContext, now)
|
||||||
|
}
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The persisted report from the last crash, or null if there is none. */
|
||||||
|
fun pendingReport(context: Context): String? {
|
||||||
|
val file = reportFile(context)
|
||||||
|
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to surface the report unprompted (on the next launch): a report
|
||||||
|
* exists and the user hasn't already waved this one away. Settings reaches
|
||||||
|
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||||
|
* auto-prompt — it doesn't discard the report.
|
||||||
|
*/
|
||||||
|
fun shouldPrompt(context: Context): Boolean =
|
||||||
|
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||||
|
|
||||||
|
/** Stop auto-prompting for the current report without discarding it. */
|
||||||
|
fun dismissPrompt(context: Context) {
|
||||||
|
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||||
|
fun clearReport(context: Context) {
|
||||||
|
runCatching { reportFile(context).delete() }
|
||||||
|
runCatching { dismissedFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app appears to be in a startup crash-loop: at least
|
||||||
|
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||||
|
* can't be trusted to start, so the caller routes straight to the standalone
|
||||||
|
* report screen instead of re-entering the crashing graph.
|
||||||
|
*/
|
||||||
|
fun isCrashLoop(context: Context): Boolean {
|
||||||
|
val times = readCrashTimes(context)
|
||||||
|
if (times.size < LOOP_THRESHOLD) return false
|
||||||
|
val recent = times.sortedDescending()
|
||||||
|
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the app as having started successfully, resetting the loop counter so
|
||||||
|
* an ordinary single crash much later never trips loop detection. The
|
||||||
|
* pending report itself is kept — only the timing trail is cleared.
|
||||||
|
*/
|
||||||
|
fun markHealthy(context: Context) {
|
||||||
|
runCatching { timesFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- persistence -------------------------------------------------------
|
||||||
|
|
||||||
|
private fun writeReport(context: Context, report: String) {
|
||||||
|
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||||
|
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||||
|
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||||
|
runCatching { dismissedFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||||
|
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||||
|
timesFile(context).apply { parentFile?.mkdirs() }
|
||||||
|
.writeText(kept.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readCrashTimes(context: Context): List<Long> {
|
||||||
|
val file = timesFile(context)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||||
|
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||||
|
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||||
|
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||||
|
|
||||||
|
private const val CRASH_DIR = "crash"
|
||||||
|
private const val REPORT_FILE = "last_crash.txt"
|
||||||
|
private const val TIMES_FILE = "crash_times.txt"
|
||||||
|
private const val DISMISSED_FILE = "dismissed"
|
||||||
|
private const val MAX_TIMES = 5
|
||||||
|
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||||
|
private const val LOOP_THRESHOLD = 2
|
||||||
|
private const val LOOP_WINDOW_MS = 10_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||||
|
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||||
|
*/
|
||||||
|
data class CrashContext(
|
||||||
|
val appVersionName: String,
|
||||||
|
val appVersionCode: Long,
|
||||||
|
val sdkInt: Int,
|
||||||
|
val androidRelease: String,
|
||||||
|
val manufacturer: String,
|
||||||
|
val model: String,
|
||||||
|
val locale: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(context: Context): CrashContext {
|
||||||
|
val pkg = runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
}.getOrNull()
|
||||||
|
return CrashContext(
|
||||||
|
appVersionName = pkg?.versionName ?: "?",
|
||||||
|
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||||
|
sdkInt = Build.VERSION.SDK_INT,
|
||||||
|
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||||
|
manufacturer = Build.MANUFACTURER ?: "?",
|
||||||
|
model = Build.MODEL ?: "?",
|
||||||
|
locale = Locale.getDefault().toLanguageTag(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||||
|
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||||
|
* tested. The leading marker doubles as the file's sanity check in
|
||||||
|
* [CrashReporter.pendingReport].
|
||||||
|
*/
|
||||||
|
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||||
|
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||||
|
val time = runCatching {
|
||||||
|
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||||
|
}.getOrDefault(nowMillis.toString())
|
||||||
|
return buildString {
|
||||||
|
appendLine("Calendula crash report")
|
||||||
|
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||||
|
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||||
|
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||||
|
appendLine("Locale: ${ctx.locale}")
|
||||||
|
appendLine("Time: $time")
|
||||||
|
appendLine()
|
||||||
|
appendLine("Stack trace:")
|
||||||
|
append(trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Android IO edge of `.ics` export: writes a serialised calendar to a
|
||||||
|
* SAF document (whole-calendar backup) or stages it in a cache file behind a
|
||||||
|
* `FileProvider` content Uri (single-event share). The serialisation itself is
|
||||||
|
* the pure `domain.ics.IcsWriter`; this class only moves bytes.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsExporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */
|
||||||
|
fun writeDocument(uri: Uri, content: String) {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
|
out.write(content.toByteArray(Charsets.UTF_8))
|
||||||
|
// Only the scheme — the full Uri can embed the user's chosen filename.
|
||||||
|
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage [content] in a private cache file and return a shareable content
|
||||||
|
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||||
|
* the receiving app. The authority mirrors the manifest's `FileProvider`.
|
||||||
|
*/
|
||||||
|
fun stageShareFile(fileName: String, content: String): Uri {
|
||||||
|
val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() }
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
file.writeText(content, Charsets.UTF_8)
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SHARE_DIR = "shared_ics"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android IO edge of `.ics` import: reads the text of a received/opened
|
||||||
|
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
|
||||||
|
* pulls bytes off the ContentResolver. Returns null on any read failure.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsImporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
fun readText(uri: Uri): String? = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -127,6 +128,97 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes before start) prefilled on new
|
||||||
|
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||||
|
* as the factory default so existing users aren't surprised by reminders they
|
||||||
|
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||||
|
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||||
|
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reminder lead time prefilled on new **all-day** events, in
|
||||||
|
* minutes before the start of the day. All-day events want day-scale lead
|
||||||
|
* times ("1 day before"), so they have their own default rather than reusing
|
||||||
|
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||||
|
* to all-day events — they always use this global value.
|
||||||
|
*/
|
||||||
|
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||||
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock time, as minutes from local midnight, at which **all-day**
|
||||||
|
* reminders fire. All-day events live at UTC midnight, so a raw "1 day
|
||||||
|
* before" would fire at an off hour (02:00 local in CEST); this time is
|
||||||
|
* encoded into the provider offset so the reminder lands at, e.g., 09:00 the
|
||||||
|
* day before instead. Global for every all-day reminder; default 09:00.
|
||||||
|
* Stored/clamped to a valid 0..1439 minute-of-day.
|
||||||
|
*/
|
||||||
|
val allDayReminderTimeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME)
|
||||||
|
.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
|
* timed default for its new events: a `null` value means "no reminder", an int
|
||||||
|
* means that lead time. A calendar **absent** from the map inherits the global
|
||||||
|
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||||
|
* no-reminder override. (All-day events ignore this and use
|
||||||
|
* [defaultAllDayReminderMinutes].)
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day**
|
||||||
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
|
* inherit the global all-day default; present null = no reminder).
|
||||||
|
*/
|
||||||
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current =
|
||||||
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
|
current.applyOverride(calendarId, override)
|
||||||
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -143,10 +235,90 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
|
internal val DEFAULT_ALLDAY_REMINDER_KEY =
|
||||||
|
stringPreferencesKey("default_allday_reminder_minutes")
|
||||||
|
internal val ALLDAY_REMINDER_TIME_KEY =
|
||||||
|
intPreferencesKey("allday_reminder_time_minutes")
|
||||||
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||||
|
sealed interface CalendarReminderOverride {
|
||||||
|
/** No override — the calendar uses the global default. */
|
||||||
|
data object Inherit : CalendarReminderOverride
|
||||||
|
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||||
|
data object None : CalendarReminderOverride
|
||||||
|
/** A specific lead time in minutes before the event start. */
|
||||||
|
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead time to prefill on a new event: the matching per-calendar override
|
||||||
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
|
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||||
|
* it can be unit-tested.
|
||||||
|
*/
|
||||||
|
fun resolveDefaultReminder(
|
||||||
|
timedGlobal: Int?,
|
||||||
|
allDayGlobal: Int?,
|
||||||
|
timedOverrides: Map<Long, Int?>,
|
||||||
|
allDayOverrides: Map<Long, Int?>,
|
||||||
|
calendarId: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
): Int? {
|
||||||
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
|
overrides[calendarId]
|
||||||
|
} else {
|
||||||
|
global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||||
|
private fun MutableMap<Long, Int?>.applyOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
|
CalendarReminderOverride.None -> put(calendarId, null)
|
||||||
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NONE = "none"
|
||||||
|
private const val ENTRY_SEP = ";"
|
||||||
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
|
||||||
|
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||||
|
null, "", NONE -> null
|
||||||
|
else -> toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||||
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
id to value
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||||
|
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the [IcsEvent] for sharing a single event. We export the event the user
|
||||||
|
* is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one
|
||||||
|
* occurrence, so "share this event" should hand off exactly that instance, not
|
||||||
|
* a whole series anchored to a possibly-different DTSTART. Reminders are the
|
||||||
|
* already-decoded semantic lead times the detail screen holds.
|
||||||
|
*/
|
||||||
|
fun EventDetail.toShareIcsEvent(): IcsEvent {
|
||||||
|
val startMillis = instance.start.toEpochMilliseconds()
|
||||||
|
return IcsEvent(
|
||||||
|
uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis),
|
||||||
|
summary = instance.title,
|
||||||
|
start = instance.start,
|
||||||
|
end = instance.end,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC",
|
||||||
|
recurrenceRule = null,
|
||||||
|
location = instance.location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.map { it.minutes },
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
// Android's calendar provider (and Calendula's own writes) use the non-standard
|
||||||
|
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
|
||||||
|
// leading T. Matched first; anything else falls through to the general grammar.
|
||||||
|
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
|
||||||
|
private val DURATION_GENERAL =
|
||||||
|
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `-PT15M`, `P1W`, …),
|
||||||
|
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
|
||||||
|
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
|
||||||
|
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
|
||||||
|
*/
|
||||||
|
fun parseRfc2445DurationMillis(duration: String?): Long {
|
||||||
|
if (duration.isNullOrBlank()) return 0L
|
||||||
|
val s = duration.trim()
|
||||||
|
|
||||||
|
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
|
||||||
|
val unitSeconds = when (m.groupValues[3]) {
|
||||||
|
"W" -> 7L * 24 * 60 * 60
|
||||||
|
"D" -> 24L * 60 * 60
|
||||||
|
else -> 1L // S
|
||||||
|
}
|
||||||
|
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
|
||||||
|
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
|
||||||
|
val days = m.groupValues[3].toLongOrNull() ?: 0L
|
||||||
|
val hours = m.groupValues[4].toLongOrNull() ?: 0L
|
||||||
|
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
|
||||||
|
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
|
||||||
|
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
|
||||||
|
return m.signum() * totalSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
|
||||||
|
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single event ready to be serialised to a `VEVENT`, decoupled from the
|
||||||
|
* provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are
|
||||||
|
* absolute instants; [isAllDay] and [recurrenceRule] decide how they are
|
||||||
|
* rendered (see [IcsWriter]'s timezone rule).
|
||||||
|
*/
|
||||||
|
data class IcsEvent(
|
||||||
|
/** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */
|
||||||
|
val uid: String,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
/** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
/** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */
|
||||||
|
val zoneId: String,
|
||||||
|
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UID to export for a provider event. A row that already carries a UID
|
||||||
|
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
|
||||||
|
* the event id and its DTSTART so the same legacy event yields the same UID
|
||||||
|
* across repeated backups — which keeps a later restore from duplicating it.
|
||||||
|
*/
|
||||||
|
fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String =
|
||||||
|
existingUid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: "$eventId-$dtStartMillis@calendula"
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
|
* how the writer encoded them.
|
||||||
|
*/
|
||||||
|
data class ParsedIcsEvent(
|
||||||
|
val uid: String?,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val zoneId: String,
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
|
enum class IcsParseWarning {
|
||||||
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
|
ModifiedOccurrenceSkipped,
|
||||||
|
|
||||||
|
/** A `VEVENT` with no parseable `DTSTART`. */
|
||||||
|
EventWithoutStartSkipped,
|
||||||
|
|
||||||
|
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
|
||||||
|
AttendeesIgnored,
|
||||||
|
|
||||||
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
|
UnknownTimezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IcsParseResult(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||||
|
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
|
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
|
||||||
|
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
|
||||||
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
|
*/
|
||||||
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
|
fun parse(text: String): IcsParseResult {
|
||||||
|
val lines = unfoldLines(text)
|
||||||
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
|
var calendarName: String? = null
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when {
|
||||||
|
line.isBegin("VEVENT") -> {
|
||||||
|
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||||
|
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||||
|
?.let(events::add)
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
line.isBegin("VTIMEZONE") -> {
|
||||||
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
|
}
|
||||||
|
line.name == "X-WR-CALNAME" -> {
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsParseResult(events, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVevent(
|
||||||
|
body: List<String>,
|
||||||
|
fileCalendarName: String?,
|
||||||
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
|
): ParsedIcsEvent? {
|
||||||
|
var uid: String? = null
|
||||||
|
var summary = ""
|
||||||
|
var dtStart: IcsDateTime? = null
|
||||||
|
var dtEnd: IcsDateTime? = null
|
||||||
|
var duration: String? = null
|
||||||
|
var rrule: String? = null
|
||||||
|
var location: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
var status = EventStatus.Confirmed
|
||||||
|
var availability = Availability.Busy
|
||||||
|
var calendarName = fileCalendarName
|
||||||
|
val reminders = mutableListOf<Int>()
|
||||||
|
var skipAsOverride = false
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < body.size) {
|
||||||
|
val line = parseContentLine(body[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when (line.name) {
|
||||||
|
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
|
||||||
|
val end = indexOfEnd(body, i + 1, "VALARM")
|
||||||
|
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
|
"DURATION" -> duration = line.value.trim()
|
||||||
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
|
"TRANSP" -> availability =
|
||||||
|
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
|
||||||
|
else Availability.Busy
|
||||||
|
"RECURRENCE-ID" -> skipAsOverride = true
|
||||||
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
|
"X-CALENDULA-CALENDAR" ->
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipAsOverride) {
|
||||||
|
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val start = dtStart ?: run {
|
||||||
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val end = dtEnd
|
||||||
|
?: duration?.let {
|
||||||
|
start.copy(
|
||||||
|
instant = Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: start
|
||||||
|
return ParsedIcsEvent(
|
||||||
|
uid = uid,
|
||||||
|
summary = summary,
|
||||||
|
start = start.instant,
|
||||||
|
end = end.instant,
|
||||||
|
isAllDay = start.isAllDay,
|
||||||
|
zoneId = start.zoneId,
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.distinct(),
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||||
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
|
val trigger = body.asSequence()
|
||||||
|
.mapNotNull { parseContentLine(it) }
|
||||||
|
.firstOrNull { it.name == "TRIGGER" }
|
||||||
|
?: return null
|
||||||
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
|
// Negative = before start (the normal case) → positive lead minutes.
|
||||||
|
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
|
val raw = line.value.trim()
|
||||||
|
val isDate = line.params["VALUE"].equals("DATE", true) ||
|
||||||
|
(raw.length == 8 && !raw.contains('T'))
|
||||||
|
if (isDate) {
|
||||||
|
val date = parseBasicDate(raw) ?: return null
|
||||||
|
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
|
||||||
|
}
|
||||||
|
val isUtc = raw.endsWith("Z")
|
||||||
|
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
|
||||||
|
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||||
|
|
||||||
|
val tzid = line.params["TZID"]
|
||||||
|
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
||||||
|
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||||
|
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||||
|
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && line.name == "END" &&
|
||||||
|
line.value.trim().equals(component, true)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return lines.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
||||||
|
"TENTATIVE" -> EventStatus.Tentative
|
||||||
|
"CANCELLED" -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||||
|
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||||
|
val date = LocalDate(
|
||||||
|
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
||||||
|
val time = LocalTime(
|
||||||
|
s.substring(9, 11).toInt(),
|
||||||
|
s.substring(11, 13).toInt(),
|
||||||
|
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the
|
||||||
|
* escaping and folding rules can be tested in isolation. Pure Kotlin — no
|
||||||
|
* Android, no time handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** iCalendar mandates CRLF line breaks, not the platform separator. */
|
||||||
|
const val ICS_CRLF: String = "\r\n"
|
||||||
|
|
||||||
|
/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */
|
||||||
|
private const val MAX_OCTETS = 75
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11:
|
||||||
|
* backslash, semicolon and comma are escaped, newlines become the literal `\n`.
|
||||||
|
* Backslash is handled first so it doesn't double-escape the others' markers.
|
||||||
|
*/
|
||||||
|
fun escapeText(value: String): String = buildString(value.length) {
|
||||||
|
for (ch in value) {
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
';' -> append("\\;")
|
||||||
|
',' -> append("\\,")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a single content line to ≤75 octets per physical line, inserting
|
||||||
|
* `CRLF + space` between segments (the space is part of the 75-octet budget of
|
||||||
|
* the continuation line, so its content caps at 74). Folding counts UTF-8
|
||||||
|
* octets, never splitting a multi-byte character across a boundary.
|
||||||
|
*/
|
||||||
|
fun foldLine(line: String): String {
|
||||||
|
if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line
|
||||||
|
val out = StringBuilder()
|
||||||
|
var octetsThisLine = 0
|
||||||
|
var first = true
|
||||||
|
var i = 0
|
||||||
|
while (i < line.length) {
|
||||||
|
val cp = line.codePointAt(i)
|
||||||
|
val width = Character.charCount(cp)
|
||||||
|
val piece = line.substring(i, i + width)
|
||||||
|
val pieceOctets = piece.toByteArray(Charsets.UTF_8).size
|
||||||
|
// Continuation lines spend one octet on the leading space.
|
||||||
|
val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1
|
||||||
|
if (octetsThisLine + pieceOctets > budget) {
|
||||||
|
out.append(ICS_CRLF).append(' ')
|
||||||
|
octetsThisLine = 0
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
out.append(piece)
|
||||||
|
octetsThisLine += pieceOctets
|
||||||
|
i += width
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
|
||||||
|
* `\,`. A backslash before any other character is dropped, keeping the
|
||||||
|
* character (lenient — foreign files escape liberally).
|
||||||
|
*/
|
||||||
|
fun unescapeText(value: String): String = buildString(value.length) {
|
||||||
|
var i = 0
|
||||||
|
while (i < value.length) {
|
||||||
|
val c = value[i]
|
||||||
|
if (c == '\\' && i + 1 < value.length) {
|
||||||
|
when (val next = value[i + 1]) {
|
||||||
|
'n', 'N' -> append('\n')
|
||||||
|
else -> append(next) // \\, \;, \, and any other escaped char
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [foldLine] across a whole document: split into physical lines on
|
||||||
|
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
|
||||||
|
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
|
||||||
|
*/
|
||||||
|
fun unfoldLines(text: String): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (physical in text.split("\r\n", "\n", "\r")) {
|
||||||
|
if (physical.isEmpty()) continue
|
||||||
|
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
|
||||||
|
if (isContinuation && out.isNotEmpty()) {
|
||||||
|
out[out.lastIndex] = out.last() + physical.substring(1)
|
||||||
|
} else {
|
||||||
|
out.add(physical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One unfolded content line split into its property name, parameters and value:
|
||||||
|
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
|
||||||
|
* `Lunch`. The value is everything after the first colon that isn't inside a
|
||||||
|
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
|
||||||
|
* Returns null for a line with no colon.
|
||||||
|
*/
|
||||||
|
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
|
||||||
|
|
||||||
|
fun parseContentLine(line: String): IcsContentLine? {
|
||||||
|
var inQuote = false
|
||||||
|
var colon = -1
|
||||||
|
for (i in line.indices) {
|
||||||
|
when (line[i]) {
|
||||||
|
'"' -> inQuote = !inQuote
|
||||||
|
':' -> if (!inQuote) { colon = i; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colon < 0) return null
|
||||||
|
val head = splitUnquoted(line.substring(0, colon), ';')
|
||||||
|
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
val params = buildMap {
|
||||||
|
for (part in head.drop(1)) {
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
put(
|
||||||
|
part.substring(0, eq).trim().uppercase(),
|
||||||
|
part.substring(eq + 1).trim().removeSurrounding("\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsContentLine(name, params, line.substring(colon + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split on [delimiter] except where it falls inside a double-quoted run. */
|
||||||
|
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
val current = StringBuilder()
|
||||||
|
var inQuote = false
|
||||||
|
for (c in text) {
|
||||||
|
when {
|
||||||
|
c == '"' -> { inQuote = !inQuote; current.append(c) }
|
||||||
|
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
|
||||||
|
else -> current.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.add(current.toString())
|
||||||
|
return parts
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Default `PRODID` advertising the writer that produced the file. */
|
||||||
|
const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal
|
||||||
|
* library: we stay on `kotlinx-datetime` and own the output, exactly as
|
||||||
|
* `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller
|
||||||
|
* supplies [dtStamp] (the export moment) so the writer never reads a clock.
|
||||||
|
*
|
||||||
|
* Timezone rule (see plan 05, decision 1):
|
||||||
|
* - all-day → `VALUE=DATE`, no zone;
|
||||||
|
* - timed one-off → UTC instant with a `Z` suffix (an instant is an instant);
|
||||||
|
* - timed recurring → `TZID`-labelled local wall time, so the series stays
|
||||||
|
* anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import
|
||||||
|
* resolves the `TZID` against the OS tz database.
|
||||||
|
*/
|
||||||
|
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||||
|
|
||||||
|
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||||
|
val lines = buildList {
|
||||||
|
add("BEGIN:VCALENDAR")
|
||||||
|
add("VERSION:2.0")
|
||||||
|
add("PRODID:$prodId")
|
||||||
|
add("CALSCALE:GREGORIAN")
|
||||||
|
events.forEach { appendEvent(it, dtStamp) }
|
||||||
|
add("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
||||||
|
add("BEGIN:VEVENT")
|
||||||
|
add("UID:${event.uid}")
|
||||||
|
add("DTSTAMP:${utcStamp(dtStamp)}")
|
||||||
|
add("SUMMARY:${escapeText(event.summary)}")
|
||||||
|
appendTimes(event)
|
||||||
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||||
|
event.location?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("LOCATION:${escapeText(it)}") }
|
||||||
|
event.description?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("DESCRIPTION:${escapeText(it)}") }
|
||||||
|
add("STATUS:${statusValue(event.status)}")
|
||||||
|
add("TRANSP:${transpValue(event.availability)}")
|
||||||
|
event.calendarName?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
|
||||||
|
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
|
||||||
|
appendAlarm(minutes, event.summary)
|
||||||
|
}
|
||||||
|
add("END:VEVENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
||||||
|
event.isAllDay -> {
|
||||||
|
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
|
||||||
|
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
|
||||||
|
}
|
||||||
|
// Recurring: anchor to wall-clock in the event's own zone.
|
||||||
|
event.recurrenceRule?.isNotBlank() == true -> {
|
||||||
|
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
|
||||||
|
if (zone != null) {
|
||||||
|
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
|
||||||
|
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
|
||||||
|
} else {
|
||||||
|
// Unknown zone id → fall back to plain UTC instants.
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
add("DTSTART:${utcStamp(event.start)}")
|
||||||
|
add("DTEND:${utcStamp(event.end)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||||
|
add("BEGIN:VALARM")
|
||||||
|
add("ACTION:DISPLAY")
|
||||||
|
add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}")
|
||||||
|
add("TRIGGER:${triggerValue(minutes)}")
|
||||||
|
add("END:VALARM")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun statusValue(status: EventStatus): String = when (status) {
|
||||||
|
EventStatus.Confirmed -> "CONFIRMED"
|
||||||
|
EventStatus.Tentative -> "TENTATIVE"
|
||||||
|
EventStatus.Cancelled -> "CANCELLED"
|
||||||
|
}
|
||||||
|
|
||||||
|
// iCal TRANSP is binary; only an explicitly free event is TRANSPARENT.
|
||||||
|
fun transpValue(availability: Availability): String =
|
||||||
|
if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE"
|
||||||
|
|
||||||
|
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
||||||
|
fun triggerValue(minutes: Int): String =
|
||||||
|
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
||||||
|
|
||||||
|
fun utcStamp(instant: Instant): String =
|
||||||
|
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
|
||||||
|
|
||||||
|
fun localStamp(instant: Instant, zone: TimeZone): String =
|
||||||
|
basic(instant.toLocalDateTime(zone))
|
||||||
|
|
||||||
|
fun utcDate(instant: Instant): String {
|
||||||
|
val dt = instant.toLocalDateTime(TimeZone.UTC)
|
||||||
|
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
|
||||||
|
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill the create form from a single parsed `.ics` event (the "open one
|
||||||
|
* event" path). [calendarId] is left null so the form preselects the last-used
|
||||||
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
|
*/
|
||||||
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = summary,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
reminders = reminderMinutes.distinct().sorted(),
|
||||||
|
availability = availability,
|
||||||
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
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.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
@@ -48,6 +50,8 @@ fun CalendarHost(
|
|||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
widgetNavRequest: WidgetNavRequest? = null,
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
@@ -121,6 +125,18 @@ fun CalendarHost(
|
|||||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
|
||||||
|
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||||
|
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||||
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
LaunchedEffect(requestedImportUri) {
|
||||||
|
if (requestedImportUri != null) {
|
||||||
|
importUri = requestedImportUri
|
||||||
|
onImportConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||||
LaunchedEffect(widgetNavRequest) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
@@ -254,5 +270,26 @@ fun CalendarHost(
|
|||||||
) {
|
) {
|
||||||
CalendarsScreen(onBack = { showCalendars = false })
|
CalendarsScreen(onBack = { showCalendars = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
|
// into the create form (prefilled, for review); many open the picker.
|
||||||
|
importUri?.let { uri ->
|
||||||
|
ImportScreen(
|
||||||
|
uri = uri,
|
||||||
|
onClose = { importUri = null },
|
||||||
|
onOpenSingle = { form ->
|
||||||
|
importUri = null
|
||||||
|
importForm = form
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
importForm?.let { form ->
|
||||||
|
EventEditScreen(
|
||||||
|
initialDateIso = null,
|
||||||
|
initialForm = form,
|
||||||
|
onClose = { importForm = null },
|
||||||
|
onSaved = { importForm = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ fun RootScreen(
|
|||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
widgetNavRequest: WidgetNavRequest? = null,
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
|
requestedImportUri: android.net.Uri? = null,
|
||||||
|
onImportConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -62,6 +64,8 @@ fun RootScreen(
|
|||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
widgetNavRequest = widgetNavRequest,
|
widgetNavRequest = widgetNavRequest,
|
||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = onImportConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -30,6 +32,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
import androidx.compose.material.icons.filled.OpenInNew
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -77,6 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
@@ -95,6 +99,7 @@ fun CalendarsScreen(
|
|||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
// [editorSession] bumps on every open so the editor's field state resets for
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -131,6 +136,9 @@ fun CalendarsScreen(
|
|||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
|
backupResult = backupResult,
|
||||||
|
onExportBackup = viewModel::exportBackup,
|
||||||
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
@@ -144,6 +152,9 @@ private fun CalendarsList(
|
|||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
|
backupResult: BackupResult?,
|
||||||
|
onExportBackup: (android.net.Uri) -> Unit,
|
||||||
|
onConsumeBackupResult: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
@@ -159,6 +170,31 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
|
// to the VM to stream the .ics into.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let(onExportBackup) }
|
||||||
|
|
||||||
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
|
LaunchedEffect(backupResult) {
|
||||||
|
when (val r = backupResult) {
|
||||||
|
is BackupResult.Success -> {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
BackupResult.Failure -> {
|
||||||
|
snackbarHostState.showSnackbar(backupFailedText)
|
||||||
|
onConsumeBackupResult()
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -195,6 +231,22 @@ private fun CalendarsList(
|
|||||||
onClick = onAdd,
|
onClick = onAdd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
|
// safety net. Offered only when there is something to back up.
|
||||||
|
if (local.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Synced calendars — read-only, grouped by account, each with a
|
// Synced calendars — read-only, grouped by account, each with a
|
||||||
@@ -429,6 +481,25 @@ private fun AccountHeader(account: String, accountType: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||||
|
@Composable
|
||||||
|
private fun LeadingAvatar(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddAvatar() {
|
private fun AddAvatar() {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -15,7 +18,9 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +32,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CalendarsViewModel @Inject constructor(
|
class CalendarsViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -45,6 +51,36 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun consumeError() { _error.value = false }
|
fun consumeError() { _error.value = false }
|
||||||
|
|
||||||
|
private val _backupResult = MutableStateFlow<BackupResult?>(null)
|
||||||
|
val backupResult: StateFlow<BackupResult?> = _backupResult.asStateFlow()
|
||||||
|
|
||||||
|
fun consumeBackupResult() { _backupResult.value = null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise every event of the writable local calendars into the chosen SAF
|
||||||
|
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||||
|
* in [backupResult] for a one-shot message.
|
||||||
|
*/
|
||||||
|
fun exportBackup(uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_backupResult.value = try {
|
||||||
|
val count = withContext(io) {
|
||||||
|
val events = repository.exportEvents()
|
||||||
|
icsExporter.writeDocument(
|
||||||
|
uri = uri,
|
||||||
|
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||||
|
)
|
||||||
|
events.size
|
||||||
|
}
|
||||||
|
BackupResult.Success(count)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
BackupResult.Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
||||||
repository.createLocalCalendar(displayName, color, description)
|
repository.createLocalCalendar(displayName, color, description)
|
||||||
}
|
}
|
||||||
@@ -69,3 +105,9 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||||
|
sealed interface BackupResult {
|
||||||
|
data class Success(val eventCount: Int) : BackupResult
|
||||||
|
data object Failure : BackupResult
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||||
|
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||||
|
* matches the card/grouped-row design language (not Material's outlined field).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogAmountField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
) {
|
||||||
|
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||||
|
// so anything lower vanishes.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { text ->
|
||||||
|
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
||||||
|
},
|
||||||
|
placeholder = placeholder,
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(72.dp)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||||
|
@Composable
|
||||||
|
fun DialogUnitDropdown(
|
||||||
|
label: String,
|
||||||
|
entries: List<String>,
|
||||||
|
onPick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
onClick = { open = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry) },
|
||||||
|
onClick = {
|
||||||
|
onPick(index)
|
||||||
|
open = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.ColumnScope
|
|||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -102,7 +104,19 @@ fun CollapsingScaffold(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
// Mark the scaffold's system-bar insets as consumed so the
|
||||||
|
// imePadding below adds only the keyboard height beyond them
|
||||||
|
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||||
|
// leaves an empty strip above the keyboard.
|
||||||
|
.consumeWindowInsets(innerPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
// Paint the surface across the full area before imePadding carves
|
||||||
|
// into it, so any sliver above the keyboard reads as surface — not
|
||||||
|
// the dialog window's black — during the IME animation.
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||||
|
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||||
|
.imePadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(top = 8.dp, bottom = 24.dp),
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
content = content,
|
content = content,
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
|
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||||
|
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||||
|
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FullScreenPicker(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// The dialog window pans by default when the keyboard opens, which —
|
||||||
|
// combined with the content's own imePadding — leaves a fixed black gap
|
||||||
|
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||||
|
// full-screen and imePadding alone lifts the focused field.
|
||||||
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
}
|
||||||
|
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General single-select picker, full-screen: each option is a connected grouped
|
||||||
|
* row and the current one carries a check. Drop-in for the former dialog
|
||||||
|
* (theme, week start, language, …).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(option),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||||
|
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||||
|
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||||
|
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderDefaultPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: CalendarReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||||
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
|
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||||
|
|
||||||
|
val options = buildList {
|
||||||
|
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||||
|
add(CalendarReminderOverride.None)
|
||||||
|
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||||
|
}
|
||||||
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderOverrideLabel(option),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
stringResource(
|
||||||
|
R.string.reminder_custom_with_value,
|
||||||
|
reminderLeadTimeLabel(selectedMinutes!!),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_edit_reminder_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = customExpanded) {
|
||||||
|
CustomReminderEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
|
* container). An amount field with a live preview of the resulting lead time, a
|
||||||
|
* single-choice unit toggle, and a tonal confirm enabled only for a valid
|
||||||
|
* 1–999 amount. [onConfirm] receives the final lead time in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomReminderEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Unit toggle first so it stays visible above the keyboard once the
|
||||||
|
// amount field (the bottom row) is focused and scrolled into view.
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = unit == entry,
|
||||||
|
onClick = { onUnitChange(entry) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||||
|
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||||
|
// all on one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
enabled = amount != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedCheck() {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||||
|
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||||
|
minutes == null -> "" to ReminderUnit.Minutes
|
||||||
|
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||||
|
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||||
|
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||||
|
else -> minutes.toString() to ReminderUnit.Minutes
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||||
|
enum class ReminderUnit(val minutesFactor: Int) {
|
||||||
|
Minutes(1),
|
||||||
|
Hours(60),
|
||||||
|
Days(1_440),
|
||||||
|
Weeks(10_080),
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
ReminderUnit.Hours -> R.string.reminder_unit_hours
|
||||||
|
ReminderUnit.Days -> R.string.reminder_unit_days
|
||||||
|
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
|
* line: "Default reminder" (negative = the provider default), "At time of
|
||||||
|
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
||||||
|
* screen, the event form and the default-reminder settings so the wording
|
||||||
|
* never drifts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||||
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
|
minutes % 10_080 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
|
minutes % 1_440 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||||
|
minutes % 60 == 0 ->
|
||||||
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 time picker in an alert dialog, seeded with [initial]. Shared by the event
|
||||||
|
* form (start/end times) and Settings (the all-day reminder fire time).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TimePickerAlert(
|
||||||
|
initial: LocalTime,
|
||||||
|
onConfirm: (LocalTime) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberTimePickerState(
|
||||||
|
initialHour = initial.hour,
|
||||||
|
initialMinute = initial.minute,
|
||||||
|
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) {
|
||||||
|
Text(stringResource(R.string.dialog_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the clock should read 24-hour, matching the rest of the device.
|
||||||
|
*
|
||||||
|
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
||||||
|
* the *app's* context locale — and this app applies a per-app language
|
||||||
|
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
||||||
|
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
||||||
|
* system 12/24 override, and otherwise fall back to the **device** locale
|
||||||
|
* (Resources.getSystem), not the app's.
|
||||||
|
*/
|
||||||
|
private fun deviceUses24HourClock(context: Context): Boolean =
|
||||||
|
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
||||||
|
"24" -> true
|
||||||
|
"12" -> false
|
||||||
|
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
||||||
|
else -> {
|
||||||
|
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
||||||
|
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately minimal, standalone surface for a captured crash report.
|
||||||
|
* `MainActivity` routes here when it detects a startup crash-loop (see
|
||||||
|
* [CrashReporter.isCrashLoop]): the main UI can't be trusted to start, so this
|
||||||
|
* screen stays clear of the app's Hilt graph, DataStore-backed theme and
|
||||||
|
* Compose content — it only reads the report file and shows the report dialog.
|
||||||
|
* Plain [CalendulaTheme] defaults (follow-system, dynamic colour) avoid touching
|
||||||
|
* anything that might be the cause of the crash.
|
||||||
|
*/
|
||||||
|
class CrashReportActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val report = CrashReporter.pendingReport(this)
|
||||||
|
if (report == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
CalendulaTheme {
|
||||||
|
// Opaque backdrop so the dialog doesn't float over a bare task.
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this, report)
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Reaching this screen breaks the loop; reset the timing trail so a
|
||||||
|
// later ordinary crash isn't mistaken for a loop.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the user to send a captured crash report as an issue. The full report is
|
||||||
|
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
||||||
|
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
||||||
|
* off to [submitCrashReport]; [onDismiss] declines.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CrashReportDialog(
|
||||||
|
report: String,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
||||||
|
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.crash_dialog_message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = report,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 220.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the captured crash report off to the user's chosen channel: the report
|
||||||
|
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
||||||
|
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
||||||
|
* sent automatically — the app has no network access; the user reviews and
|
||||||
|
* submits the issue themselves.
|
||||||
|
*/
|
||||||
|
fun submitCrashReport(context: Context, report: String) {
|
||||||
|
copyReportToClipboard(context, report)
|
||||||
|
val opened = runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
||||||
|
}.isSuccess
|
||||||
|
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
||||||
|
fun openIssueTracker(context: Context) {
|
||||||
|
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
||||||
|
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyReportToClipboard(context: Context, report: String) {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||||
|
val label = context.getString(R.string.crash_report_clip_label)
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
||||||
|
* can blow past URL-length limits, so an over-long one is left out of the link
|
||||||
|
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
||||||
|
* source of truth in that case.
|
||||||
|
*/
|
||||||
|
private fun buildIssueUri(context: Context, report: String) =
|
||||||
|
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
||||||
|
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
||||||
|
.appendQueryParameter("body", buildIssueBody(context, report))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun buildIssueBody(context: Context, report: String): String {
|
||||||
|
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||||
|
context.getString(R.string.crash_report_body_paste)
|
||||||
|
} else {
|
||||||
|
"```\n$report\n```"
|
||||||
|
}
|
||||||
|
return context.getString(R.string.crash_report_body_template, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||||
|
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Place
|
|||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -59,6 +60,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -67,7 +69,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
@@ -96,6 +97,8 @@ import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -132,9 +135,30 @@ fun EventDetailScreen(
|
|||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
|
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||||
|
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
val onShareClick = {
|
||||||
|
scope.launch {
|
||||||
|
val uri = viewModel.shareUri()
|
||||||
|
val sent = uri != null && runCatching {
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/calendar"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
||||||
|
}.isSuccess
|
||||||
|
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action.
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
var pendingEdit by remember { mutableStateOf(false) }
|
||||||
@@ -203,9 +227,18 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Only writable calendars get actions — WebCal subscriptions,
|
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
|
||||||
val s = state
|
val s = state
|
||||||
|
// Share works for any loaded event — it only reads the event.
|
||||||
|
if (s is EventDetailUiState.Success) {
|
||||||
|
IconButton(onClick = onShareClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Share,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_share),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
if (s is EventDetailUiState.Success && s.canModify) {
|
if (s is EventDetailUiState.Success && s.canModify) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onEditClick,
|
onClick = onEditClick,
|
||||||
@@ -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". */
|
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLeadText(reminder: Reminder): String {
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
val minutes = reminder.minutes
|
|
||||||
return when {
|
|
||||||
minutes < 0 -> stringResource(R.string.reminder_default)
|
|
||||||
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 -> {
|
|
||||||
val weeks = minutes / 10_080
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
|
|
||||||
}
|
|
||||||
minutes % 1_440 == 0 -> {
|
|
||||||
val days = minutes / 1_440
|
|
||||||
pluralStringResource(R.plurals.reminder_days, days, days)
|
|
||||||
}
|
|
||||||
minutes % 60 == 0 -> {
|
|
||||||
val hours = minutes / 60
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, hours, hours)
|
|
||||||
}
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||||
@@ -762,14 +776,19 @@ private fun formatWhen(
|
|||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|
||||||
if (instance.isAllDay) {
|
if (instance.isAllDay) {
|
||||||
// All-day end is the exclusive next midnight; step back to the last
|
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||||
// covered day so a one-day event reads as a single date.
|
// the covered dates in UTC — not the device zone, which would shift the
|
||||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
// midnight boundaries off the intended date (east of UTC pushes the
|
||||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
// end past the last day; west of UTC pulls the start back) — and step
|
||||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
// the end back to the last covered day so a one-day event reads as a
|
||||||
|
// single date.
|
||||||
|
val utc = ZoneId.of("UTC")
|
||||||
|
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
return if (startDate == lastDate) {
|
||||||
|
allDayLabel to dateFull.format(startDate)
|
||||||
} else {
|
} else {
|
||||||
allDayLabel to
|
allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
|
||||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -34,6 +40,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EventDetailViewModel @Inject constructor(
|
class EventDetailViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -113,6 +120,24 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
_deleteState.value = DeleteUiState.Idle
|
_deleteState.value = DeleteUiState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise the open event to a `.ics` cache file and return a shareable
|
||||||
|
* content Uri (for an ACTION_SEND), or null when nothing is loaded or the
|
||||||
|
* write fails. The event is exported as a one-off (see [toShareIcsEvent]).
|
||||||
|
*/
|
||||||
|
suspend fun shareUri(): Uri? {
|
||||||
|
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||||
|
return runCatching {
|
||||||
|
withContext(io) {
|
||||||
|
val ics = IcsWriter().writeCalendar(
|
||||||
|
events = listOf(detail.toShareIcsEvent()),
|
||||||
|
dtStamp = Clock.System.now(),
|
||||||
|
)
|
||||||
|
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
@@ -143,3 +168,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
private fun shareFileName(title: String): String {
|
||||||
|
val base = title.trim()
|
||||||
|
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
||||||
|
.replace(' ', '_')
|
||||||
|
.take(40)
|
||||||
|
.ifBlank { "event" }
|
||||||
|
return "$base.ics"
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,10 +68,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TimePicker
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -86,7 +84,6 @@ import androidx.compose.ui.graphics.SolidColor
|
|||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -101,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
|||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
@@ -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.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -152,19 +157,23 @@ fun EventEditScreen(
|
|||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
|
initialForm: EventForm? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
if (editKey != null) {
|
when {
|
||||||
viewModel.openForEdit(
|
// Single-event .ics open: the form arrives prefilled for review.
|
||||||
|
initialForm != null -> viewModel.openImported(initialForm)
|
||||||
|
editKey != null -> viewModel.openForEdit(
|
||||||
eventId = editKey[0],
|
eventId = editKey[0],
|
||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
endMillis = editKey[2],
|
endMillis = editKey[2],
|
||||||
)
|
)
|
||||||
} else {
|
else -> {
|
||||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
viewModel.openNew(date, initialStartMinutes)
|
viewModel.openNew(date, initialStartMinutes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -916,14 +925,7 @@ private fun FieldPickerDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||||
private val REMINDER_QUICK_PICKS = listOf(0, 10, 30, 60, 1_440)
|
private val REMINDER_QUICK_PICKS = REMINDER_PRESETS
|
||||||
|
|
||||||
private enum class ReminderUnit(val minutesFactor: Int) {
|
|
||||||
Minutes(1),
|
|
||||||
Hours(60),
|
|
||||||
Days(1_440),
|
|
||||||
Weeks(10_080),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder picker, two steps: the common lead times as a tappable list
|
* Reminder picker, two steps: the common lead times as a tappable list
|
||||||
@@ -1245,84 +1247,6 @@ private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMa
|
|||||||
private fun Int.toDaySet(): Set<DayOfWeek> =
|
private fun Int.toDaySet(): Set<DayOfWeek> =
|
||||||
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
|
||||||
|
|
||||||
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
|
|
||||||
@Composable
|
|
||||||
private fun DialogAmountField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
) {
|
|
||||||
// surfaceContainerHighest — the dialog itself sits on
|
|
||||||
// surfaceContainerHigh, so anything lower vanishes.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
InlineField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = { text ->
|
|
||||||
if (text.length <= 3 && text.all(Char::isDigit)) {
|
|
||||||
onValueChange(text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = placeholder,
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(72.dp)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
|
|
||||||
@Composable
|
|
||||||
private fun DialogUnitDropdown(
|
|
||||||
label: String,
|
|
||||||
entries: List<String>,
|
|
||||||
onPick: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
var open by remember { mutableStateOf(false) }
|
|
||||||
Box {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
onClick = { open = true },
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 14.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
top = 12.dp,
|
|
||||||
bottom = 12.dp,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
|
||||||
entries.forEachIndexed { index, entry ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(entry) },
|
|
||||||
onClick = {
|
|
||||||
onPick(index)
|
|
||||||
open = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||||
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
RecurrenceFreq.Daily -> R.string.recurrence_daily
|
||||||
@@ -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) {
|
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
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. */
|
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLabel(minutes: Int): String = when {
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
minutes <= 0 -> stringResource(R.string.reminder_at_time)
|
|
||||||
minutes % 10_080 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
|
||||||
minutes % 1_440 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
|
||||||
minutes % 60 == 0 ->
|
|
||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's DetailCard: tonal container,
|
* One info card mirroring the detail screen's DetailCard: tonal container,
|
||||||
@@ -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
|
@Composable
|
||||||
private fun CalendarPickerDialog(
|
private fun CalendarPickerDialog(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@@ -71,6 +73,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// Set while the form edits an existing event instead of composing a new one.
|
// Set while the form edits an existing event instead of composing a new one.
|
||||||
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
private val _editTarget = MutableStateFlow<EditTarget?>(null)
|
||||||
private val _loadFailed = MutableStateFlow(false)
|
private val _loadFailed = MutableStateFlow(false)
|
||||||
|
// True once the user has hand-edited the reminders on a new event, which
|
||||||
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
|
// their choice. Reset with the form.
|
||||||
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
@@ -100,6 +106,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val editTarget: EditTarget?,
|
val editTarget: EditTarget?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val timed: Int?,
|
||||||
|
val allDay: Int?,
|
||||||
|
val timedOverrides: Map<Long, Int?>,
|
||||||
|
val allDayOverrides: Map<Long, Int?>,
|
||||||
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
@@ -194,6 +207,63 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||||
|
* the create form" path). [form] already carries the file's fields; its
|
||||||
|
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||||
|
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||||
|
* settings default never overwrites what the file specified. No-op when a
|
||||||
|
* form is already open, so the prefill survives configuration changes.
|
||||||
|
*/
|
||||||
|
fun openImported(form: EventForm) {
|
||||||
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
_remindersTouched.value = true
|
||||||
|
_revealed.value = form.populatedFields()
|
||||||
|
_form.value = form
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill a new event's reminders from the settings default — the all-day
|
||||||
|
* default for all-day events, otherwise the resolved calendar's per-calendar
|
||||||
|
* override or the global timed default. No-op while editing an existing event
|
||||||
|
* or once the user has hand-edited the reminders, so the auto-default never
|
||||||
|
* clobbers a manual choice. [calendarId] short-circuits the resolution after a
|
||||||
|
* calendar switch; null resolves it as the form does.
|
||||||
|
*/
|
||||||
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val defaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
|
val form = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
|
val default = resolveDefaultReminder(
|
||||||
|
timedGlobal = defaults.timed,
|
||||||
|
allDayGlobal = defaults.allDay,
|
||||||
|
timedOverrides = defaults.timedOverrides,
|
||||||
|
allDayOverrides = defaults.allDayOverrides,
|
||||||
|
calendarId = targetId,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
)
|
||||||
|
val reminders = listOfNotNull(default)
|
||||||
|
_form.value = form.copy(reminders = reminders)
|
||||||
|
// Surface the section so an auto-applied default is visible and
|
||||||
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
|
if (reminders.isNotEmpty()) {
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +299,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_revealed.value = emptySet()
|
_revealed.value = emptySet()
|
||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
|
_remindersTouched.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -239,14 +310,24 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
fun setAllDay(value: Boolean) {
|
||||||
|
update { it.copy(isAllDay = value) }
|
||||||
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
|
applyDefaultReminder()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching calendars drops any chosen colour: a palette key is
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
* The event falls back to the new calendar's colour until re-picked.
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
|
fun setCalendar(id: Long) {
|
||||||
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
|
// A fresh event re-inherits the new calendar's default reminder unless
|
||||||
|
// the user has already hand-edited it (guarded inside).
|
||||||
|
applyDefaultReminder(id)
|
||||||
|
}
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
@@ -262,12 +343,14 @@ class EventEditViewModel @Inject constructor(
|
|||||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
fun addReminder(minutes: Int) = update {
|
fun addReminder(minutes: Int) {
|
||||||
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeReminder(minutes: Int) = update {
|
fun removeReminder(minutes: Int) {
|
||||||
it.copy(reminders = it.reminders - minutes)
|
_remindersTouched.value = true
|
||||||
|
update { it.copy(reminders = it.reminders - minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moving the start drags the end along, preserving the duration. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
|
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||||
|
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||||
|
* Empty/failed files show a short message and close.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ImportScreen(
|
||||||
|
uri: Uri,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
|
||||||
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.import_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when (val s = state) {
|
||||||
|
ImportUiState.Loading,
|
||||||
|
ImportUiState.Importing,
|
||||||
|
is ImportUiState.Single,
|
||||||
|
-> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
|
||||||
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
|
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
||||||
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
||||||
|
// No writable calendar to import into — tell the user honestly.
|
||||||
|
if (state.calendars.isEmpty()) {
|
||||||
|
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_target_header),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
state.calendars.forEach { calendar ->
|
||||||
|
OptionCard(
|
||||||
|
label = calendar.displayName,
|
||||||
|
onClick = { selected = calendar.id },
|
||||||
|
selected = calendar.id == selected,
|
||||||
|
icon = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.warnings.forEach { WarningText(it) }
|
||||||
|
Button(
|
||||||
|
onClick = { onImport(selected) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_done_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_imported,
|
||||||
|
state.summary.imported,
|
||||||
|
state.summary.imported,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_skipped,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
state.summary.skippedDuplicate,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.import_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WarningText(warning: IcsParseWarning) {
|
||||||
|
val text = when (warning) {
|
||||||
|
IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
|
||||||
|
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||||
|
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||||
|
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (onClose != null) {
|
||||||
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** What an opened/received `.ics` resolved to. */
|
||||||
|
sealed interface ImportUiState {
|
||||||
|
data object Loading : ImportUiState
|
||||||
|
data object Importing : ImportUiState
|
||||||
|
|
||||||
|
/** The file held no importable event (or couldn't be read/parsed as one). */
|
||||||
|
data object Empty : ImportUiState
|
||||||
|
data object Failed : ImportUiState
|
||||||
|
|
||||||
|
/** Exactly one event → review it in the prefilled create form. */
|
||||||
|
data class Single(val form: EventForm, val warnings: Set<IcsParseWarning>) : ImportUiState
|
||||||
|
|
||||||
|
/** Several events → pick a target calendar and bulk-import. */
|
||||||
|
data class Many(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
) : ImportUiState
|
||||||
|
|
||||||
|
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
|
||||||
|
* one event opens the create form for review, many open the bulk-import picker.
|
||||||
|
* The bulk import dedups by UID in the repository.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ImportViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val importer: IcsImporter,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val parser = IcsParser()
|
||||||
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
||||||
|
fun load(uri: Uri) {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val parsed = withContext(io) {
|
||||||
|
importer.readText(uri)?.let(parser::parse)
|
||||||
|
}
|
||||||
|
_state.value = when {
|
||||||
|
parsed == null -> ImportUiState.Failed
|
||||||
|
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||||
|
parsed.events.size == 1 -> ImportUiState.Single(
|
||||||
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
)
|
||||||
|
else -> ImportUiState.Many(
|
||||||
|
events = parsed.events,
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
calendars = repository.calendars().first().filter { it.canModifyContents },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
||||||
|
fun import(targetCalendarId: Long) {
|
||||||
|
val many = _state.value as? ImportUiState.Many ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = ImportUiState.Importing
|
||||||
|
_state.value = try {
|
||||||
|
ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ImportUiState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,78 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/** UI-facing language choice. AUTO follows the system languages. */
|
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||||
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
|
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||||
* platform per-app-languages API; below that the appcompat backport persists
|
*
|
||||||
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
|
* That file is the single source of truth for which languages we ship: dropping
|
||||||
* it in DataStore. Setting a locale recreates the activity, which re-reads the
|
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||||
* current value for the dropdown.
|
* language show up here and in the system per-app-language settings, with no
|
||||||
|
* other code change. The system-default choice is represented as `null`.
|
||||||
|
*
|
||||||
|
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||||
|
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||||
|
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||||
|
* activity, which re-reads the current value for the picker.
|
||||||
*/
|
*/
|
||||||
object AppLanguage {
|
object AppLanguage {
|
||||||
|
|
||||||
fun current(): LanguagePref {
|
/**
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||||
if (locales.isEmpty) return LanguagePref.AUTO
|
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||||
return when (locales[0]?.language) {
|
* or malformed config yields an empty list (the picker then offers only the
|
||||||
"de" -> LanguagePref.GERMAN
|
* system-default entry rather than crashing).
|
||||||
"en" -> LanguagePref.ENGLISH
|
*/
|
||||||
else -> LanguagePref.AUTO
|
fun supportedTags(context: Context): List<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
val parser = context.resources.getXml(R.xml.locales_config)
|
||||||
|
try {
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fall back to whatever was parsed before the failure.
|
||||||
|
} finally {
|
||||||
|
parser.close()
|
||||||
}
|
}
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
fun apply(pref: LanguagePref) {
|
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||||
val locales = when (pref) {
|
fun currentTag(): String? {
|
||||||
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||||
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
}
|
||||||
|
|
||||||
|
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||||
|
fun apply(tag: String?) {
|
||||||
|
val locales = if (tag == null) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||||
|
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||||
|
* of the current UI language. Capitalised per the language's own rules.
|
||||||
|
*/
|
||||||
|
fun displayName(tag: String): String {
|
||||||
|
val locale = Locale.forLanguageTag(tag)
|
||||||
|
return locale.getDisplayName(locale)
|
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -30,21 +33,24 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -63,17 +69,32 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||||
@@ -180,19 +201,59 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||||
onClick = onManageCalendars,
|
onClick = onManageCalendars,
|
||||||
)
|
)
|
||||||
LanguageRow(position = Position.Bottom)
|
LanguageRow(position = Position.Middle)
|
||||||
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
AppVersionText()
|
AppVersionText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the project's issue tracker to report a problem. If a crash report was
|
||||||
|
* captured (and not yet sent), it surfaces that report first via the same
|
||||||
|
* dialog the next-launch prompt uses; otherwise it opens the issue template
|
||||||
|
* chooser. No data leaves the device until the user submits the issue.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReportProblemRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var report by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_report_problem),
|
||||||
|
summary = stringResource(R.string.settings_report_problem_hint),
|
||||||
|
position = position,
|
||||||
|
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
|
||||||
|
onClick = {
|
||||||
|
val pending = CrashReporter.pendingReport(context)
|
||||||
|
if (pending != null) report = pending else openIssueTracker(context)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
report?.let { pending ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = pending,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(context, pending)
|
||||||
|
CrashReporter.clearReport(context)
|
||||||
|
report = null
|
||||||
|
},
|
||||||
|
onDismiss = { report = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// row updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.current()) }
|
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
summary = languageLabel(current),
|
summary = languageLabel(current),
|
||||||
@@ -202,9 +263,9 @@ private fun LanguageRow(position: Position) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
options = LanguagePref.entries,
|
options = options,
|
||||||
selected = current,
|
selected = current,
|
||||||
label = { languageLabel(it) },
|
label = { languageLabel(it) },
|
||||||
onSelect = {
|
onSelect = {
|
||||||
@@ -378,7 +439,7 @@ private fun AppearanceScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.themeMode,
|
selected = state.themeMode,
|
||||||
@@ -388,7 +449,7 @@ private fun AppearanceScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPickerDialog(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
options = WeekStartPref.entries,
|
options = WeekStartPref.entries,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
@@ -482,6 +543,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(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -489,13 +556,273 @@ private fun NotificationsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
summary = stringResource(R.string.settings_reminders_hint),
|
summary = stringResource(R.string.settings_reminders_hint),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||||
},
|
},
|
||||||
onClick = { toggleReminders(!state.remindersEnabled) },
|
onClick = { toggleReminders(!state.remindersEnabled) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAllDayReminder = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_allday_reminder_time),
|
||||||
|
summary = stringResource(
|
||||||
|
R.string.settings_allday_reminder_time_hint,
|
||||||
|
formatTimeOfDay(context, state.allDayReminderTimeMinutes),
|
||||||
|
),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showAllDayReminderTime = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-calendar overrides: each writable calendar may keep, drop, or
|
||||||
|
// replace the global default — separately for timed and all-day events.
|
||||||
|
if (state.writableCalendars.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_calendar_reminders_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
state.writableCalendars.forEach { calendar ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
val expanded = calendar.id in expandedCalendars
|
||||||
|
// Calendar card; tapping expands it into a grouped list of three
|
||||||
|
// (the card + the timed and all-day override rows).
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expandedCalendars = if (expanded) {
|
||||||
|
expandedCalendars - calendar.id
|
||||||
|
} else {
|
||||||
|
expandedCalendars + calendar.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = expanded) {
|
||||||
|
Column {
|
||||||
|
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
|
)
|
||||||
|
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery reliability: Android's battery optimisation can delay or drop
|
||||||
|
// the calendar provider's reminder broadcast. A soft, optional exemption
|
||||||
|
// (system-settings deep-link, no special permission) improves on-time
|
||||||
|
// delivery; shown as live status, reversible by the user at any time.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reliable_delivery),
|
||||||
|
summary = if (batteryExempt) {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_exempt)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_reliable_delivery_hint)
|
||||||
|
},
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = if (batteryExempt) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +858,6 @@ private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OptionCard selection dialog — the app's only sanctioned picker style. */
|
|
||||||
@Composable
|
|
||||||
private fun <T> OptionPickerDialog(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(title) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
options.forEach { option ->
|
|
||||||
OptionCard(
|
|
||||||
label = label(option),
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
selected = option == selected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openUrl(context: Context, url: String) {
|
private fun openUrl(context: Context, url: String) {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
@@ -598,10 +893,5 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun languageLabel(pref: LanguagePref): String = stringResource(
|
private fun languageLabel(tag: String?): String =
|
||||||
when (pref) {
|
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||||
LanguagePref.AUTO -> R.string.settings_language_auto
|
|
||||||
LanguagePref.GERMAN -> R.string.settings_language_german
|
|
||||||
LanguagePref.ENGLISH -> R.string.settings_language_english
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,25 @@ data class SettingsUiState(
|
|||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||||
|
* null = no default reminder. Per-calendar overrides take precedence.
|
||||||
|
*/
|
||||||
|
val defaultReminderMinutes: Int? = null,
|
||||||
|
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||||
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
|
* calendar present in the map overrides the global default (null value = no
|
||||||
|
* reminder); absent = inherit the global default.
|
||||||
|
*/
|
||||||
|
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||||
|
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||||
|
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||||
|
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||||
/**
|
/**
|
||||||
* Whether the event-colour picker is offered on calendars that publish no
|
* Whether the event-colour picker is offered on calendars that publish no
|
||||||
* colour palette (the colour may then not survive their next sync).
|
* colour palette (the colour may then not survive their next sync).
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ import android.os.Build
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -18,14 +24,20 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
repository: CalendarRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
/** Writable calendars — the only ones that take a per-calendar reminder override. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(
|
combine(
|
||||||
// combine() only types up to five flows, so the sixth pref folds
|
// combine() types up to five flows, so the prefs split into two
|
||||||
// into the assembled state in an outer combine.
|
// groups that fold together in the outer combine.
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
@@ -42,15 +54,50 @@ class SettingsViewModel @Inject constructor(
|
|||||||
remindersEnabled = reminders,
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
prefs.allowColorOnUnsupportedCalendars,
|
combine(
|
||||||
) { base, allowColor ->
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
prefs.defaultReminderMinutes,
|
||||||
|
prefs.defaultAllDayReminderMinutes,
|
||||||
|
prefs.allDayReminderTimeMinutes,
|
||||||
|
) { 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(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ReminderDefaults(
|
||||||
|
val allowColor: Boolean,
|
||||||
|
val defaultReminder: Int?,
|
||||||
|
val allDayReminder: Int?,
|
||||||
|
val allDayReminderTime: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderOverrides(
|
||||||
|
val timed: Map<Long, Int?>,
|
||||||
|
val allDay: Map<Long, Int?>,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -71,6 +118,26 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||||
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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). */
|
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||||
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||||
|
if (isAllDay) {
|
||||||
|
// All-day events live at UTC midnights with an exclusive end. Compare
|
||||||
|
// calendar dates in UTC and step the exclusive end back to the last
|
||||||
|
// covered day (mirroring the detail/edit views), so a one-day event
|
||||||
|
// covers exactly its single date. Slicing the day in the device zone
|
||||||
|
// would push the exclusive end a few hours into the next local day
|
||||||
|
// east of UTC, making the event leak onto day + 1.
|
||||||
|
val startDate = start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
|
||||||
|
return day in startDate..lastDay
|
||||||
|
}
|
||||||
val dayStart = day.atStartOfDayIn(zone)
|
val dayStart = day.atStartOfDayIn(zone)
|
||||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
return start < dayEnd && end > dayStart
|
return start < dayEnd && end > dayStart
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
<string name="event_detail_back">Zurück</string>
|
<string name="event_detail_back">Zurück</string>
|
||||||
<string name="event_detail_edit">Bearbeiten</string>
|
<string name="event_detail_edit">Bearbeiten</string>
|
||||||
<string name="event_detail_delete">Löschen</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_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_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
||||||
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
||||||
@@ -248,14 +251,26 @@
|
|||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
<string name="settings_reminders">Termin-Erinnerungen</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_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_section_calendars">Kalender</string>
|
||||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||||
<string name="settings_section_language">Sprache</string>
|
<string name="settings_section_language">Sprache</string>
|
||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</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 -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
@@ -267,6 +282,8 @@
|
|||||||
<string name="settings_about_source">Quellcode</string>
|
<string name="settings_about_source">Quellcode</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||||
|
<string name="settings_report_problem">Problem melden</string>
|
||||||
|
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
||||||
|
|
||||||
<!-- Calendar manager -->
|
<!-- Calendar manager -->
|
||||||
<string name="calendars_title">Kalender</string>
|
<string name="calendars_title">Kalender</string>
|
||||||
@@ -285,4 +302,53 @@
|
|||||||
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
|
<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_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>
|
<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>
|
||||||
|
|
||||||
|
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||||
|
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
||||||
|
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||||
|
<string name="crash_dialog_report">Melden</string>
|
||||||
|
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
||||||
|
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||||
|
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
|
||||||
|
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
||||||
|
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||||
|
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
6
app/src/main/res/values-night/colors.xml
Normal file
6
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||||
@@ -3,4 +3,8 @@
|
|||||||
<color name="seed">#FF5C6B7A</color>
|
<color name="seed">#FF5C6B7A</color>
|
||||||
<!-- Adaptive icon background -->
|
<!-- Adaptive icon background -->
|
||||||
<color name="ic_launcher_background">#FF5C6B7A</color>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
<string name="event_detail_back">Back</string>
|
<string name="event_detail_back">Back</string>
|
||||||
<string name="event_detail_edit">Edit</string>
|
<string name="event_detail_edit">Edit</string>
|
||||||
<string name="event_detail_delete">Delete</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_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_body">The event is removed from your calendar and from every device it syncs to.</string>
|
||||||
<string name="event_delete_recurring_title">Delete recurring event</string>
|
<string name="event_delete_recurring_title">Delete recurring event</string>
|
||||||
@@ -245,14 +248,26 @@
|
|||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">Event reminders</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_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_section_calendars">Calendars</string>
|
||||||
<string name="settings_manage_calendars">Manage 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_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</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 -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
@@ -264,6 +279,8 @@
|
|||||||
<string name="settings_about_source">Source</string>
|
<string name="settings_about_source">Source</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||||
|
<string name="settings_report_problem">Report a problem</string>
|
||||||
|
<string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string>
|
||||||
|
|
||||||
<!-- Calendar manager -->
|
<!-- Calendar manager -->
|
||||||
<string name="calendars_title">Calendars</string>
|
<string name="calendars_title">Calendars</string>
|
||||||
@@ -282,10 +299,62 @@
|
|||||||
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
<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_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>
|
<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 -->
|
<!-- Launcher long-press shortcuts -->
|
||||||
<string name="shortcut_new_event_short">New event</string>
|
<string name="shortcut_new_event_short">New event</string>
|
||||||
<string name="shortcut_new_event_long">Create a new event</string>
|
<string name="shortcut_new_event_long">Create a new event</string>
|
||||||
|
|
||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||||
|
|
||||||
|
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||||
|
Gitea issue (the app sends nothing automatically). -->
|
||||||
|
<string name="crash_dialog_title">Calendula crashed</string>
|
||||||
|
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
||||||
|
<string name="crash_dialog_report">Report</string>
|
||||||
|
<string name="crash_dialog_dismiss">Not now</string>
|
||||||
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
|
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||||
|
<string name="crash_report_copied">Report copied to your clipboard</string>
|
||||||
|
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||||
|
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||||
|
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new</string>
|
||||||
|
<string name="report_issue_choose_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<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:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
|||||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
13
app/src/main/res/xml/locales_config.xml
Normal file
13
app/src/main/res/xml/locales_config.xml
Normal 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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -28,6 +29,13 @@ class CalendarRepositoryImplTest {
|
|||||||
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
||||||
CalendarPrefs(newDataStore(tempDir))
|
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> =
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
PreferenceDataStoreFactory.create(
|
PreferenceDataStoreFactory.create(
|
||||||
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||||
@@ -53,7 +61,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
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 {
|
repo.calendars().test {
|
||||||
val first = awaitItem()
|
val first = awaitItem()
|
||||||
@@ -67,7 +75,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
calendarsResult = listOf(makeCal(1L))
|
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 {
|
repo.calendars().test {
|
||||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||||
@@ -91,7 +99,7 @@ class CalendarRepositoryImplTest {
|
|||||||
listOf(makeEvent(10L))
|
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)
|
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -107,7 +115,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
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)
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
repo.instances(range).test {
|
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)
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
repo.instances(range).test {
|
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)
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -165,7 +173,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
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(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Stand-up",
|
title = "Stand-up",
|
||||||
@@ -179,12 +187,32 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.insertedForms).containsExactly(form)
|
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
|
@Test
|
||||||
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("insert event")
|
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(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
@@ -202,7 +230,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
val original = EventForm(
|
val original = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Stand-up",
|
title = "Stand-up",
|
||||||
@@ -221,7 +249,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("update event id=42")
|
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(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
@@ -239,7 +267,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
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)
|
repo.deleteEvent(eventId = 42L)
|
||||||
|
|
||||||
@@ -250,7 +278,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
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)
|
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||||
|
|
||||||
@@ -261,7 +289,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
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)
|
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||||
|
|
||||||
@@ -273,7 +301,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
|
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(
|
val form = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Moved",
|
title = "Moved",
|
||||||
@@ -291,7 +319,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
|
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(
|
val original = EventForm(
|
||||||
calendarId = 1L,
|
calendarId = 1L,
|
||||||
title = "Weekly",
|
title = "Weekly",
|
||||||
@@ -318,7 +346,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("delete event id=42")
|
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 {
|
try {
|
||||||
repo.deleteEvent(eventId = 42L)
|
repo.deleteEvent(eventId = 42L)
|
||||||
@@ -331,7 +359,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextCalendarId = 501L }
|
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(
|
val id = repo.createLocalCalendar(
|
||||||
displayName = "Home",
|
displayName = "Home",
|
||||||
@@ -348,7 +376,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
|
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
repo.updateCalendar(
|
repo.updateCalendar(
|
||||||
id = 5L,
|
id = 5L,
|
||||||
@@ -365,7 +393,7 @@ class CalendarRepositoryImplTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
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)
|
repo.deleteCalendar(id = 7L)
|
||||||
|
|
||||||
@@ -377,7 +405,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
writeError = WriteFailedException("create local calendar 'Home'")
|
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 {
|
try {
|
||||||
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
|
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
|
||||||
@@ -392,7 +420,7 @@ class CalendarRepositoryImplTest {
|
|||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
eventDetailResult = { null }
|
eventDetailResult = { null }
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repo.eventDetail(eventId = 999L)
|
repo.eventDetail(eventId = 999L)
|
||||||
@@ -411,10 +439,41 @@ class CalendarRepositoryImplTest {
|
|||||||
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
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))
|
assertThat(repo.eventColorPalette(7L))
|
||||||
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||||
assertThat(repo.eventColorPalette(8L)).isEmpty()
|
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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
* 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 instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
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. */
|
/** Set to make the next write call throw. */
|
||||||
var writeError: Exception? = null
|
var writeError: Exception? = null
|
||||||
/** Id returned by the next [insertEvent]. */
|
/** Id returned by the next [insertEvent]. */
|
||||||
@@ -49,6 +54,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
eventColorPaletteResult(calendarId)
|
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 {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
@@ -66,20 +83,36 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
deletedCalendarIds += id
|
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 }
|
writeError?.let { throw it }
|
||||||
insertedForms += form
|
insertedForms += form
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
return nextInsertId
|
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 }
|
writeError?.let { throw it }
|
||||||
updatedEvents += Triple(eventId, original, updated)
|
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 }
|
writeError?.let { throw it }
|
||||||
updatedOccurrences += Triple(eventId, beginMillis, form)
|
updatedOccurrences += Triple(eventId, beginMillis, form)
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +121,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.crash
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class CrashReportBuilderTest {
|
||||||
|
|
||||||
|
private val context = CrashContext(
|
||||||
|
appVersionName = "2.7.0",
|
||||||
|
appVersionCode = 20700,
|
||||||
|
sdkInt = 34,
|
||||||
|
androidRelease = "14",
|
||||||
|
manufacturer = "Google",
|
||||||
|
model = "Pixel 7",
|
||||||
|
locale = "en-DE",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `report carries the allowlisted facts and the stack trace`() {
|
||||||
|
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
|
||||||
|
|
||||||
|
assertThat(report).startsWith("Calendula crash report")
|
||||||
|
assertThat(report).contains("App version: 2.7.0 (20700)")
|
||||||
|
assertThat(report).contains("Android: 14 (API 34)")
|
||||||
|
assertThat(report).contains("Device: Google Pixel 7")
|
||||||
|
assertThat(report).contains("Locale: en-DE")
|
||||||
|
// The exception type + message and a frame from this test are present.
|
||||||
|
assertThat(report).contains("IllegalStateException")
|
||||||
|
assertThat(report).contains("boom")
|
||||||
|
assertThat(report).contains("CrashReportBuilderTest")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nested causes are included`() {
|
||||||
|
val cause = NullPointerException("inner")
|
||||||
|
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
|
||||||
|
|
||||||
|
assertThat(report).contains("outer")
|
||||||
|
assertThat(report).contains("Caused by")
|
||||||
|
assertThat(report).contains("inner")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `report holds only the allowlisted lines before the stack trace`() {
|
||||||
|
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
|
||||||
|
val header = report.substringBefore("Stack trace:").trim().lines()
|
||||||
|
|
||||||
|
// No identifiers, accounts, or extra fields ever creep into the header:
|
||||||
|
// it is exactly the six allowlisted lines plus the title.
|
||||||
|
assertThat(header).hasSize(6)
|
||||||
|
assertThat(header.first()).isEqualTo("Calendula crash report")
|
||||||
|
assertThat(header.map { it.substringBefore(":") }).containsExactly(
|
||||||
|
"Calendula crash report", "App version", "Android", "Device", "Locale", "Time",
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,118 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
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
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,13 +62,28 @@ class WeekLayoutTest {
|
|||||||
assertThat(ev.coversDay(wed, zone)).isTrue()
|
assertThat(ev.coversDay(wed, zone)).isTrue()
|
||||||
assertThat(ev.coversDay(mon, zone)).isFalse()
|
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(mon, zone)).isTrue()
|
||||||
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), 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()
|
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
|
@Test
|
||||||
fun `single timed event gets one lane`() {
|
fun `single timed event gets one lane`() {
|
||||||
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
|
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
|
||||||
|
|||||||
150
docs/superpowers/plans/2026-06-18-05-ics-export.md
Normal file
150
docs/superpowers/plans/2026-06-18-05-ics-export.md
Normal 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.
|
||||||
122
docs/superpowers/plans/2026-06-18-06-ics-import.md
Normal file
122
docs/superpowers/plans/2026-06-18-06-ics-import.md
Normal 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
|
||||||
94
scripts/check_translations.py
Executable file
94
scripts/check_translations.py
Executable 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())
|
||||||
Reference in New Issue
Block a user