Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07a8faf2df | |||
| 54b059e744 | |||
| d0c01ab8c4 | |||
| bf10fee19f | |||
| 2e88c40eff | |||
| 26e538a9ba | |||
| 814daa8151 | |||
| 450fc7f7af | |||
| be6d10d126 | |||
| a1e8c05c7b | |||
| cecd0c2234 | |||
| 43eb5e23e8 | |||
| 946addbbcd | |||
| 8a63aa86a7 | |||
| 0ddbbb11e8 | |||
| 575b1c7eb7 | |||
| dd61e0ed37 | |||
| 376ad5f394 | |||
| 8a9e6f0ee8 | |||
| 913b49e962 | |||
| 96f4d4dc84 | |||
| f4789c535e | |||
| 00220c5f0e | |||
| b7a5f16c84 | |||
| 2c38912834 | |||
| a9c67a5827 |
@@ -230,7 +230,7 @@ pass on the existing controls; new toggles ride in with their own features.
|
|||||||
|
|
||||||
**Tier 3 — platform reach (depends on Tier 2)**
|
**Tier 3 — platform reach (depends on Tier 2)**
|
||||||
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(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)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
|
||||||
|
|
||||||
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||||
@@ -251,37 +251,38 @@ pass on the existing controls; new toggles ride in with their own features.
|
|||||||
decision (snooze must re-fire an alarm).
|
decision (snooze must re-fire an alarm).
|
||||||
|
|
||||||
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
||||||
12. **Attendee editing** *(next — promoted from the gated "Locations & People"
|
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
|
||||||
bucket, owner says high-importance 2026-06-22)* — attendees are already
|
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
|
||||||
*read* (queried, mapped, shown on the detail screen since v0.6) but the
|
the event model: attendees were already *read* (queried, mapped, shown on the
|
||||||
event form can't *write* them, the last big read-only gap in the event
|
detail screen since v0.6) and are now *writable* from the form. An attendees
|
||||||
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
|
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
|
||||||
typed email (contact-picker entry can follow later), edit/remove rows, set
|
no-permission contact picker, edits/removes rows, and sets role (required /
|
||||||
role (required / optional) — writing `CalendarContract.Attendees` rows on
|
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
|
||||||
insert + dirty-checked update, mirroring the reminders-diff pattern.
|
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
|
||||||
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
|
caveat was **settled record-only** (Calendula never sends invites; the backend
|
||||||
*invitation* behavior that differs by backend (Google auto-emails invites;
|
decides delivery) — full sketch + decision in "Attendee editing" under Locations
|
||||||
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
|
& People below.
|
||||||
decide and document what Calendula promises before building. Full sketch in
|
|
||||||
"Attendee editing" under Locations & People below.
|
|
||||||
|
|
||||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||||
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
|
||||||
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
**Bottom — deprioritised, not important**
|
**Bottom — deprioritised, not important**
|
||||||
- Duplicate event (detail action → prefilled create form) — moved here
|
- Duplicate event (detail action → prefilled create form) — moved here
|
||||||
2026-06-17; cheap but low value, pick up only if asked
|
2026-06-17; cheap but low value, pick up only if asked
|
||||||
|
|
||||||
**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. Pulled in opportunistically, not sequenced.
|
Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||||
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected).
|
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
|
||||||
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap
|
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
|
||||||
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
|
||||||
(quick-settings tile, now-line, week numbers in month, full-text search).
|
full-text search, the "New event" Quick Settings tile, and the now-line all
|
||||||
|
shipped there. **No tier work is currently committed as next;** the remaining
|
||||||
|
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
|
||||||
|
accessibility pass) plus the gated go/no-go items.
|
||||||
|
|
||||||
## Navigation & views
|
## Navigation & views
|
||||||
|
|
||||||
@@ -291,24 +292,22 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
|||||||
- 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,
|
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
||||||
currently absent. Daily-driver polish.
|
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
||||||
- Week numbers in the **month** grid — week view already shows the badge
|
the view and shrinks the day cells; the badge stays week-view-only.
|
||||||
(`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)* — promote out of "fill-in": for a daily driver
|
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
|
||||||
with real event history, finding an event is core completeness, not optional.
|
history, finding an event is core completeness, not optional.
|
||||||
|
|
||||||
## Event editing & creation
|
## Event editing & creation
|
||||||
|
|
||||||
- Duplicate event (detail action → prefilled create form)
|
- Duplicate event (detail action → prefilled create form)
|
||||||
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
||||||
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
||||||
management work: reuses the color-picker component and palette plumbing
|
picker in the form, falling back to the calendar color when unset. Reused the
|
||||||
being built for local calendar management, and finishes the create/edit
|
color-picker component and palette plumbing from local calendar management and
|
||||||
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
|
finished the create/edit theme.
|
||||||
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
|
|
||||||
|
|
||||||
## Calendars & accounts
|
## Calendars & accounts
|
||||||
|
|
||||||
@@ -337,7 +336,63 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
|||||||
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||||
UID). Closed the silent data-loss gap.
|
UID). Closed the silent data-loss gap.
|
||||||
|
|
||||||
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
### Disable a calendar in-app *(captured 2026-06-25)*
|
||||||
|
|
||||||
|
A second, heavier visibility level **above** the existing per-view filter. Today
|
||||||
|
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
||||||
|
a calendar's *events* from the month/week/day/agenda views — the calendar itself
|
||||||
|
still clutters the drawer filter list, the event-form calendar picker, and the
|
||||||
|
import target picker. "Disable" removes a calendar from the app's surfaces
|
||||||
|
entirely; "hide" stays the lightweight, frequently-toggled control.
|
||||||
|
|
||||||
|
**Two-level model (both kept):**
|
||||||
|
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
|
||||||
|
drawer. Toggles events on/off in the views; the calendar stays listed
|
||||||
|
everywhere. Operates only over the *enabled* calendars.
|
||||||
|
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
|
||||||
|
filter list, not in the event-form picker, not in the import picker, and its
|
||||||
|
events never appear (it's gated out of `instances()` like a hidden one). It
|
||||||
|
remains visible **only** in Settings → Calendars, where the enable/disable
|
||||||
|
toggle lives, so it can be brought back.
|
||||||
|
|
||||||
|
**Storage — app-side (DataStore), mirrors the hidden set.** Add
|
||||||
|
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
|
||||||
|
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
|
||||||
|
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
|
||||||
|
a Calendula-local preference, so other calendar apps are unaffected and the sync
|
||||||
|
adapters stay out of it (privacy-clean, reversible).
|
||||||
|
|
||||||
|
**Where the disabled set is applied:**
|
||||||
|
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
|
||||||
|
`calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show
|
||||||
|
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
|
||||||
|
the screens that need everything still get everything.)
|
||||||
|
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
|
||||||
|
(you can't hide/show what's disabled).
|
||||||
|
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
|
||||||
|
into a calendar you've removed from the app. Handle the last-used-calendar
|
||||||
|
preselect falling on a now-disabled calendar (fall back to first enabled
|
||||||
|
writable).
|
||||||
|
- `ImportViewModel` — exclude disabled from the import target list.
|
||||||
|
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
|
||||||
|
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
|
||||||
|
|
||||||
|
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
|
||||||
|
row on the existing `CalendarsScreen` (both the local and the synced/read-only
|
||||||
|
groups — disabling is an app-side view choice, independent of write access).
|
||||||
|
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
|
||||||
|
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
|
||||||
|
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
|
||||||
|
|
||||||
|
**Decided behaviour:**
|
||||||
|
- All calendars disabled → views show the existing empty state.
|
||||||
|
- A disabled calendar that still holds events: events simply vanish from
|
||||||
|
views/search until re-enabled (no data touched — it's a filter, not a delete).
|
||||||
|
- Deep links / notifications pointing at an event in a disabled calendar still
|
||||||
|
open its detail screen — detail is a direct id lookup, not an `instances()`
|
||||||
|
query, so disabling never strands an existing link.
|
||||||
|
|
||||||
|
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
|
||||||
|
|
||||||
Two themes bundled because both are "make reminders trustworthy" — the core of
|
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||||
the "Calendula is your only calendar app" promise.
|
the "Calendula is your only calendar app" promise.
|
||||||
@@ -414,18 +469,20 @@ it directly undermines the feature's premise, so it rides in here.
|
|||||||
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
are notorious for suppressing it).
|
are notorious for suppressing it).
|
||||||
|
|
||||||
### Open decisions (resolve before building)
|
### Decisions made (as shipped in v2.6.0)
|
||||||
|
|
||||||
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
|
||||||
(reliability vs simplicity + battery cost).
|
battery-optimisation exemption is the reliability lever (simplicity + battery
|
||||||
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
cost won over the exact-alarm subsystem).
|
||||||
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
|
||||||
a list inside the Notifications sub-page).
|
time-of-day ("9am the day before") deferred — see the deferred list above.
|
||||||
|
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
|
||||||
|
governing timed events only.
|
||||||
|
|
||||||
### Later (round two)
|
### Round two
|
||||||
|
|
||||||
- Snooze + dismiss actions on the notification (snooze needs an
|
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
|
||||||
exact-alarm / WorkManager decision) — Tier 4 #13.
|
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
|
||||||
|
|
||||||
## Sharing & interop
|
## Sharing & interop
|
||||||
|
|
||||||
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
|
|||||||
## Platform & launchers
|
## Platform & launchers
|
||||||
|
|
||||||
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
||||||
optional quick-settings tile still open
|
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
||||||
|
|
||||||
## Quality & reliability
|
## Quality & reliability
|
||||||
|
|
||||||
@@ -455,9 +512,10 @@ it directly undermines the feature's premise, so it rides in here.
|
|||||||
Beyond classic calendar-client scope; discussed, deliberately not planned
|
Beyond classic calendar-client scope; discussed, deliberately not planned
|
||||||
in detail yet:
|
in detail yet:
|
||||||
|
|
||||||
- **Contact address picker** for the location field via the system picker
|
- ~~**Contact address picker** for the location field via the system picker
|
||||||
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
|
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
|
||||||
fits the privacy story. Same mechanism later for picking emails.
|
the privacy story. The same no-permission mechanism was then reused for the
|
||||||
|
attendee email picker (v2.8.0).
|
||||||
- **OSM address autocomplete** in the location field (type "Brandenburger
|
- **OSM address autocomplete** in the location field (type "Brandenburger
|
||||||
Tor" → tap suggestion → resolved address inserted). Backend would be
|
Tor" → tap suggestion → resolved address inserted). Backend would be
|
||||||
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.10.0] — 2026-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Turn a calendar off across the whole app. Settings → Calendars now has a
|
||||||
|
switch on every calendar — both your own and synced ones. Switching one off
|
||||||
|
removes it everywhere: its events disappear from all views and search, and it
|
||||||
|
drops out of the drawer's hide/show filter, the event-form calendar picker and
|
||||||
|
the import target picker. Unlike hiding (the quick per-view checkbox in the
|
||||||
|
drawer), a disabled calendar leaves the app entirely until you turn it back on.
|
||||||
|
Nothing is deleted and no other app is affected — it's a Calendula-only view
|
||||||
|
choice — so you can re-enable it any time from the same screen, where disabled
|
||||||
|
calendars stay listed but dimmed.
|
||||||
|
- An optional way to support development. Settings → About now has a "Support
|
||||||
|
development" button that opens Ko-fi in your browser. It's a plain donation
|
||||||
|
link with no perks attached, and it needs no new permissions — Calendula still
|
||||||
|
has no internet access of its own and just hands the link to your browser.
|
||||||
|
|
||||||
## [2.9.0] — 2026-06-25
|
## [2.9.0] — 2026-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
59
README.md
59
README.md
@@ -15,6 +15,12 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||||
|
|
||||||
|
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||||
@@ -68,13 +74,20 @@ database, no sync stack reinvented.
|
|||||||
|
|
||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|
||||||
Calendula ships through a self-hosted F-Droid repository; every version tag
|
### F-Droid (recommended)
|
||||||
is built, signed, and published there automatically.
|
|
||||||
|
|
||||||
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
|
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||||
Store, …).
|
just search for **Calendula** in any F-Droid client, or
|
||||||
2. Add the repository — open this link on your phone, or paste it under
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||||
*Settings → Repositories → Add*:
|
|
||||||
|
### Self-hosted repo (latest builds)
|
||||||
|
|
||||||
|
New versions are built, signed, and published to a self-hosted repository the
|
||||||
|
moment each tag lands — usually a few days ahead of the official repo, which
|
||||||
|
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
||||||
|
|
||||||
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
|
link below on your phone):
|
||||||
|
|
||||||
```
|
```
|
||||||
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
||||||
@@ -84,36 +97,16 @@ is built, signed, and published there automatically.
|
|||||||
fingerprint (SHA-256):
|
fingerprint (SHA-256):
|
||||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||||
|
|
||||||
3. Refresh, search for **Calendula**, install. Updates arrive like any
|
2. Refresh, search for **Calendula**, install.
|
||||||
other F-Droid app.
|
|
||||||
|
|
||||||
Alternatively, build from source — see below.
|
Both channels share the same signing key, so you can switch between them
|
||||||
|
without reinstalling. Or build from source — see below.
|
||||||
|
|
||||||
## 🛠 Building
|
## 📚 Documentation
|
||||||
|
|
||||||
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
|
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||||
|
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||||
```bash
|
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||||
./gradlew assembleDebug # debug APK
|
|
||||||
./gradlew test # JVM unit tests
|
|
||||||
./gradlew lint # Android lint
|
|
||||||
```
|
|
||||||
|
|
||||||
If your default JDK is not 17, set `JAVA_HOME` explicitly.
|
|
||||||
|
|
||||||
## 🏗 Architecture
|
|
||||||
|
|
||||||
Single-activity Compose app, layered `UI → Repository → DataSource →
|
|
||||||
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
|
|
||||||
including the recurring-write and reminder pipelines — lives in
|
|
||||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
||||||
|
|
||||||
## 🗺 Roadmap
|
|
||||||
|
|
||||||
Shipped: read (v1.0), write (v1.1–v2.0), reminder delivery (v1.4).
|
|
||||||
Next up: power-user features — widget, search, tablet layouts. The living
|
|
||||||
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
|
|
||||||
history in [CHANGELOG.md](CHANGELOG.md).
|
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 20900
|
versionCode = 21000
|
||||||
versionName = "2.9.0"
|
versionName = "2.10.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
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
|
||||||
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
.reQuery { dataSource.calendars() }
|
.reQuery { dataSource.calendars() }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||||
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||||
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||||
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||||
// so the filter sheet can list and re-enable hidden calendars.
|
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||||
|
// list and re-enable hidden/disabled calendars.
|
||||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
combine(
|
combine(
|
||||||
ticks
|
ticks
|
||||||
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { instances, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
if (hidden.isEmpty()) instances
|
) { instances, hidden, disabled ->
|
||||||
else instances.filterNot { it.calendarId in hidden }
|
val excluded = hidden + disabled
|
||||||
}.flowOn(io)
|
if (excluded.isEmpty()) instances
|
||||||
|
else instances.filterNot { it.calendarId in excluded }
|
||||||
|
}
|
||||||
|
// hidden and disabled both derive from one DataStore, so toggling
|
||||||
|
// either makes both re-emit and combine briefly surfaces the same
|
||||||
|
// list twice — collapse the duplicate so views don't re-render for it.
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val hidden = prefs.hiddenCalendarIds.first()
|
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-side preference for "calendars the user has disabled in this app" — a
|
||||||
|
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||||
|
* every surface (drawer filter, event-form picker, import picker) and its
|
||||||
|
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||||
|
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||||
|
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||||
|
*/
|
||||||
|
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||||
|
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
prefs.remove(DISABLED_IDS_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar the user last created an event in; preselected in the
|
* The calendar the user last created an event in; preselected in the
|
||||||
* event form. Null until the first event is created.
|
* event form. Null until the first event is created.
|
||||||
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
|
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
import androidx.compose.material.icons.filled.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
|
||||||
@@ -45,6 +44,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
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.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -58,11 +58,14 @@ 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
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -98,6 +101,7 @@ fun CalendarsScreen(
|
|||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -134,6 +138,7 @@ fun CalendarsScreen(
|
|||||||
CalendarsList(
|
CalendarsList(
|
||||||
local = calendars.filter { it.isLocal },
|
local = calendars.filter { it.isLocal },
|
||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
|
disabledIds = disabledIds,
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
backupResult = backupResult,
|
backupResult = backupResult,
|
||||||
@@ -142,6 +147,7 @@ fun CalendarsScreen(
|
|||||||
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 },
|
||||||
|
onSetDisabled = viewModel::setDisabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +156,7 @@ fun CalendarsScreen(
|
|||||||
private fun CalendarsList(
|
private fun CalendarsList(
|
||||||
local: List<CalendarSource>,
|
local: List<CalendarSource>,
|
||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
|
disabledIds: Set<Long>,
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
backupResult: BackupResult?,
|
backupResult: BackupResult?,
|
||||||
@@ -158,6 +165,7 @@ private fun CalendarsList(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
|
onSetDisabled: (Long, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -206,19 +214,21 @@ private fun CalendarsList(
|
|||||||
if (local.isEmpty()) {
|
if (local.isEmpty()) {
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
}
|
}
|
||||||
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
val localCount = local.size + 1
|
val localCount = local.size + 1
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, localCount),
|
position = positionOf(index, localCount),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
dimmed = disabled,
|
||||||
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
Icon(
|
EnableSwitch(
|
||||||
Icons.Default.Edit,
|
calendarName = calendar.displayName,
|
||||||
contentDescription = stringResource(R.string.calendars_edit_title),
|
enabled = !disabled,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onEdit(calendar) },
|
onClick = { onEdit(calendar) },
|
||||||
@@ -258,10 +268,19 @@ private fun CalendarsList(
|
|||||||
.forEach { (account, cals) ->
|
.forEach { (account, cals) ->
|
||||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||||
cals.forEachIndexed { index, calendar ->
|
cals.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
position = positionOf(index, cals.size),
|
position = positionOf(index, cals.size),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
dimmed = disabled,
|
||||||
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
|
trailing = {
|
||||||
|
EnableSwitch(
|
||||||
|
calendarName = calendar.displayName,
|
||||||
|
enabled = !disabled,
|
||||||
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,6 +443,30 @@ private fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-row enable/disable control. Checked = the calendar is shown in the
|
||||||
|
* app; unchecking disables it (events, filters and pickers all drop it) without
|
||||||
|
* touching any provider data. Carries its own content description so the toggle
|
||||||
|
* is self-describing to screen readers even on a dimmed row.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EnableSwitch(
|
||||||
|
calendarName: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = onToggle,
|
||||||
|
modifier = Modifier.semantics { contentDescription = label },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
|
||||||
|
private fun dimIf(disabled: Boolean): Modifier =
|
||||||
|
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||||
|
|
||||||
/** Tonal field card matching the event editor's design (icon + content). */
|
/** Tonal field card matching the event editor's design (icon + content). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditorCard(
|
private fun EditorCard(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
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
|
||||||
@@ -33,6 +35,7 @@ import javax.inject.Inject
|
|||||||
class CalendarsViewModel @Inject constructor(
|
class CalendarsViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -46,6 +49,20 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars the user has disabled in the app. This screen is the only
|
||||||
|
* surface that lists them, so it both reads the set (to dim the rows) and
|
||||||
|
* toggles it. Every other surface simply excludes these ids.
|
||||||
|
*/
|
||||||
|
val disabledCalendarIds: StateFlow<Set<Long>> =
|
||||||
|
prefs.disabledCalendarIds
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -93,6 +110,19 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
repository.deleteCalendar(id)
|
repository.deleteCalendar(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable a calendar app-side. Disabling removes it from every
|
||||||
|
* surface but Settings → Calendars (and hides its events) without touching
|
||||||
|
* provider data — purely a reversible Calendula-local view choice.
|
||||||
|
*/
|
||||||
|
fun setDisabled(id: Long, disabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val current = prefs.disabledCalendarIds.first()
|
||||||
|
val next = if (disabled) current + id else current - id
|
||||||
|
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -127,7 +127,9 @@ fun CollapsingScaffold(
|
|||||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||||
* corner radii come from its [position] (so a run of rows reads as a single
|
* corner radii come from its [position] (so a run of rows reads as a single
|
||||||
* rounded card). Corners round further on press. A null [onClick] makes the
|
* rounded card). Corners round further on press. A null [onClick] makes the
|
||||||
* row non-interactive (e.g. read-only entries).
|
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
|
||||||
|
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
||||||
|
* full opacity — for rows that are present but switched off.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -137,6 +139,7 @@ fun GroupedRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
|
dimmed: Boolean = false,
|
||||||
minHeight: Dp = 72.dp,
|
minHeight: Dp = 72.dp,
|
||||||
leading: @Composable (() -> Unit)? = null,
|
leading: @Composable (() -> Unit)? = null,
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
@@ -168,6 +171,16 @@ fun GroupedRow(
|
|||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
)
|
)
|
||||||
|
} else if (dimmed) {
|
||||||
|
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
||||||
|
// stays full-opacity so the toggle reads as live even on a faded row.
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = muted,
|
||||||
|
leadingIconColor = muted,
|
||||||
|
supportingColor = muted,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allowColorOnUnsupported: Boolean,
|
val allowColorOnUnsupported: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Writable calendars — the only valid event targets. */
|
/**
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
.catch { emit(emptyList()) }
|
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||||
|
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||||
|
*/
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||||
|
repository.calendars(),
|
||||||
|
prefs.disabledCalendarIds,
|
||||||
|
) { calendars, disabled ->
|
||||||
|
calendars.filter { it.canModifyContents && it.id !in disabled }
|
||||||
|
}.catch { emit(emptyList()) }
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { calendars, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
if (calendars.isEmpty()) {
|
) { calendars, hidden, disabled ->
|
||||||
|
// Disabled calendars are gone from the app entirely — they don't
|
||||||
|
// belong in the drawer's hide/show list (you can't hide what's
|
||||||
|
// already disabled). They live only in Settings → Calendars.
|
||||||
|
val enabled = calendars.filterNot { it.id in disabled }
|
||||||
|
if (enabled.isEmpty()) {
|
||||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupByAccount(calendars, hidden))
|
FilterUiState.Success(groupByAccount(enabled, hidden))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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.IcsImporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
|
|||||||
class ImportViewModel @Inject constructor(
|
class ImportViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val importer: IcsImporter,
|
private val importer: IcsImporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -79,14 +81,20 @@ class ImportViewModel @Inject constructor(
|
|||||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> ImportUiState.Many(
|
else -> {
|
||||||
|
// A disabled calendar is removed from the app, so it can't be
|
||||||
|
// an import target — exclude it alongside the read-only ones.
|
||||||
|
val disabled = prefs.disabledCalendarIds.first()
|
||||||
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first().filter { it.canModifyContents },
|
calendars = repository.calendars().first()
|
||||||
|
.filter { it.canModifyContents && it.id !in disabled },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
||||||
fun import(targetCalendarId: Long) {
|
fun import(targetCalendarId: Long) {
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ import androidx.compose.material.icons.filled.CalendarMonth
|
|||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
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.FilledTonalButton
|
||||||
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
|
||||||
@@ -292,6 +294,7 @@ private fun AboutCard() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sourceUrl = stringResource(R.string.about_source_url)
|
val sourceUrl = stringResource(R.string.about_source_url)
|
||||||
val licenseUrl = stringResource(R.string.about_license_url)
|
val licenseUrl = stringResource(R.string.about_license_url)
|
||||||
|
val supportUrl = stringResource(R.string.about_support_url)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
@@ -350,6 +353,19 @@ private fun AboutCard() {
|
|||||||
Text(stringResource(R.string.settings_license))
|
Text(stringResource(R.string.settings_license))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { openUrl(context, supportUrl) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Favorite,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_about_support))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,7 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Quellcode</string>
|
<string name="settings_about_source">Quellcode</string>
|
||||||
|
<string name="settings_about_support">Entwicklung unterstützen</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">Problem melden</string>
|
||||||
|
|||||||
@@ -308,6 +308,7 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Source</string>
|
<string name="settings_about_source">Source</string>
|
||||||
|
<string name="settings_about_support">Support development</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">Report a problem</string>
|
||||||
@@ -318,6 +319,8 @@
|
|||||||
<string name="calendars_local_header">Your calendars</string>
|
<string name="calendars_local_header">Your calendars</string>
|
||||||
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
|
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
|
||||||
<string name="calendars_add">Add calendar</string>
|
<string name="calendars_add">Add calendar</string>
|
||||||
|
<string name="calendars_disable_hint">Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
|
||||||
<string name="calendars_synced_header">Synced calendars</string>
|
<string name="calendars_synced_header">Synced calendars</string>
|
||||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||||
<string name="calendars_manage_in_app">Manage</string>
|
<string name="calendars_manage_in_app">Manage</string>
|
||||||
@@ -378,6 +381,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||||
|
|
||||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||||
Gitea issue (the app sends nothing automatically). -->
|
Gitea issue (the app sends nothing automatically). -->
|
||||||
|
|||||||
@@ -170,6 +170,74 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = newPrefs(tempDir)
|
||||||
|
prefs.setDisabledCalendarIds(setOf(2L))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
makeEvent(10L, "Enabled", calendarId = 1L),
|
||||||
|
makeEvent(11L, "Disabled", calendarId = 2L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = newPrefs(tempDir)
|
||||||
|
prefs.setHiddenCalendarIds(setOf(2L))
|
||||||
|
prefs.setDisabledCalendarIds(setOf(3L))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
makeEvent(10L, "Shown", calendarId = 1L),
|
||||||
|
makeEvent(11L, "Hidden", calendarId = 2L),
|
||||||
|
makeEvent(12L, "Disabled", calendarId = 3L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = newPrefs(tempDir)
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
makeEvent(10L, "A", calendarId = 1L),
|
||||||
|
makeEvent(11L, "B", calendarId = 2L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||||
|
|
||||||
|
prefs.setDisabledCalendarIds(setOf(2L))
|
||||||
|
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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 }
|
||||||
|
|||||||
@@ -50,4 +50,34 @@ class CalendarPrefsTest {
|
|||||||
}
|
}
|
||||||
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
|
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L))
|
||||||
|
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setDisabledCalendarIds(setOf(1L))
|
||||||
|
prefs.setDisabledCalendarIds(emptySet())
|
||||||
|
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setHiddenCalendarIds(setOf(1L))
|
||||||
|
prefs.setDisabledCalendarIds(setOf(2L))
|
||||||
|
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
|
||||||
|
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
docs/BUILDING.md
Normal file
24
docs/BUILDING.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Building from source
|
||||||
|
|
||||||
|
Calendula builds with the standard Android Gradle toolchain — no extra setup
|
||||||
|
beyond the SDK and a JDK.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Android SDK 36+**
|
||||||
|
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||||
|
|
||||||
|
The Gradle wrapper is checked in, so you don't need a system Gradle.
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
|
||||||
|
./gradlew test # JVM unit tests
|
||||||
|
./gradlew lint # Android lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release builds
|
||||||
|
|
||||||
|
Release signing and the publishing pipeline are documented separately in
|
||||||
|
[RELEASING.md](RELEASING.md).
|
||||||
@@ -21,6 +21,7 @@ AuthorName: Jean-Luc Makiola
|
|||||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||||
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
||||||
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|
||||||
AutoName: Calendula
|
AutoName: Calendula
|
||||||
|
|
||||||
|
|||||||
14
fastlane/metadata/android/en-US/changelogs/21000.txt
Normal file
14
fastlane/metadata/android/en-US/changelogs/21000.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
### Added
|
||||||
|
- Turn a calendar off across the whole app. Settings → Calendars now has a
|
||||||
|
switch on every calendar — both your own and synced ones. Switching one off
|
||||||
|
removes it everywhere: its events disappear from all views and search, and it
|
||||||
|
drops out of the drawer's hide/show filter, the event-form calendar picker and
|
||||||
|
the import target picker. Unlike hiding (the quick per-view checkbox in the
|
||||||
|
drawer), a disabled calendar leaves the app entirely until you turn it back on.
|
||||||
|
Nothing is deleted and no other app is affected — it's a Calendula-only view
|
||||||
|
choice — so you can re-enable it any time from the same screen, where disabled
|
||||||
|
calendars stay listed but dimmed.
|
||||||
|
- An optional way to support development. Settings → About now has a "Support
|
||||||
|
development" button that opens Ko-fi in your browser. It's a plain donation
|
||||||
|
link with no perks attached, and it needs no new permissions — Calendula still
|
||||||
|
has no internet access of its own and just hands the link to your browser.
|
||||||
@@ -8,3 +8,4 @@ Categories:
|
|||||||
|
|
||||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||||
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|||||||
Reference in New Issue
Block a user