3 Commits

Author SHA1 Message Date
b62f097392 release: cut v2.4.0 — per-event colors
All checks were successful
CI / ci (push) Successful in 9m20s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 9m22s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m3s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 7s
Optional per-event color in the event form. The read/render path already
resolved EVENT_COLOR with a calendar fallback; this adds the write side and
the picker.

- Palette-backed calendars (Google, some CalDAV) pick from the account's
  Colors (TYPE_EVENT) and write EVENT_COLOR_KEY, so the color round-trips
  through sync; local calendars write a raw EVENT_COLOR from the shared
  CALENDAR_COLOR_PALETTE. Never writes a raw color to a palette calendar.
- Swatch row + palette extracted to ui/common/ColorSwatchRow.kt (shared with
  the calendar editor). Switching calendars resets the choice (keys are
  account-scoped); a "Reset" action returns to the calendar color.
- New "Allow colors on unsupported calendars" setting (off by default)
  extends the raw path to no-palette synced calendars, with an honest
  "may not survive sync" warning on the picker and in Settings.
- Color flows through insert / dirty-checked update / occurrence-exception;
  mapper, form, and repository tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:55:16 +02:00
210ddff8d8 release: cut v2.3.0 — Material 3 grouped-list redesign of Settings, calendars & drawer
All checks were successful
CI / ci (push) Successful in 8m2s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m1s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m44s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s
One shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
and extracted to ui/common/GroupedList.kt: CollapsingScaffold (a LargeTopAppBar
whose large title collapses into the bar on scroll) and GroupedRow
(Position-based corner grouping so a run of rows reads as one rounded card, with
press-animated corners and selected/minHeight knobs).

Settings: restructured into a category hub (About card on top, version mark at
the foot) with sliding sub-pages for Appearance, the new-event form and
Notifications. Theme, week-start and language pickers migrated from DropdownMenu
to OptionCard dialogs; token-based icon chips. New ic_gitea.xml (Simple Icons,
verbatim path) for the About "Source" button; en+de strings.

Calendar manager: same collapsing scaffold + grouped rows; shared
CalendarColorChip (neutral chip with a pastelised calendar glyph) replaces the
bright colour swatch.

Navigation drawer: branded header, grouped View switcher (active view
highlighted via secondaryContainer), filter list restyled to grouped rows with a
trailing checkbox; the whole drawer now scrolls as one.

Cards use surfaceContainerHigh for readable contrast against surface. Version
bumped to 2.3.0 / 20300. UI-only; unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:44:10 +02:00
e194da3766 release: cut v2.2.0 — tap-to-create + local calendar management
All checks were successful
CI / ci (push) Successful in 8m53s
Release — F-Droid repo + Gitea release / ci (push) Successful in 1m59s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m57s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s
Day/week: tap an empty slot to open the create form prefilled with that
day and the tapped hour (snapped to the hour, 1 h long). Threaded a start
time through CalendarHost → EventEditScreen → openNew; the FAB keeps its
default.

Local calendars: a full-screen editor from Settings → Calendars to
create/rename/recolor/delete device-only calendars (ACCOUNT_TYPE_LOCAL,
sync-adapter insert) with name, pastel-previewed colour, and a description
(stored in CAL_SYNC1). Synced calendars are listed read-only grouped by
account, each with a "manage in source app" deep-link resolved from the
account's own authenticator (DAVx5/ICSx5/…), plus an add-account shortcut;
a <queries> block makes the source apps launchable. Extracted a shared
InlineTextField into ui.common so the event form and calendar editor share
one borderless input style.

Tests: repository delegation + write-failure, mapper isLocal/description,
fake data source extended. Version bumped to 2.2.0 / 20200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:49:14 +02:00
42 changed files with 2918 additions and 518 deletions

View File

@@ -107,43 +107,209 @@ Deliberately deferred (add only if needed):
- Snooze / dismiss notification actions (Etar has them) - Snooze / dismiss notification actions (Etar has them)
- Battery-optimization exemption prompt for delivery reliability - Battery-optimization exemption prompt for delivery reliability
## v3.0 — Power-User Features ## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
- Home-screen widget - Month grid shows real events as continuous multi-day bars (not just dots)
- Full-text search - View section in the navigation drawer to switch Month / Week / Day
- Tablet / foldable layouts - Fix: text cursor no longer jumps in event text fields
- Optional: ICS file import (drag-and-drop)
- Optional: move event to another calendar (copy+delete model with a
consequences warning — deferred from v2.0, see above)
Order is indicative — community feedback after V1 may re-prioritize. ## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
## Idea backlog — Daily-driver polish (captured 2026-06-11, all approved as ideas, unscheduled) - Tap an empty slot in day/week → create form prefilled with that day + the
tapped hour (snapped to the hour, 1 h long)
- Local (device-only) calendar management in a full-screen editor from
Settings → Calendars: create / rename / recolor / delete, with name,
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
- Synced calendars listed read-only, grouped by account, each with a
per-account "manage in source app" deep-link (resolved from the account's
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
editor share one input style)
Interaction: ## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
- Tap/long-press an empty slot in day/week → create form prefilled with that time
- Drag & drop rescheduling in day/week (recurring drops reuse the scope dialog) — big-ticket, own slice A structural + visual pass adopting one shared blueprint (modelled on the ReFra
- Agenda view (fourth view: upcoming events grouped by day; natural widget data source) gallery app) across Settings, the calendar manager and the navigation drawer.
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
whose title collapses on scroll) + `GroupedRow` (Position-based corner
grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub with About card on top and sliding sub-pages
(Appearance / New event form / Notifications); theme/week-start/language
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
chips; `ic_gitea.xml` for the About "Source" button.
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
drawer scrolls as one with the active view highlighted.
- Cards use `surfaceContainerHigh` for readable contrast.
- Donate button on the About card deferred (target TBD).
---
# Backlog (theme-based, post-v2.1)
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
consolidated here by theme. Within a group, **(in progress)** /
**(next)** mark what is being or about to be worked; everything else is an
approved-but-unscheduled idea unless tagged **(idea)** /
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
## Near-term sequence (ranked, 2026-06-16)
The theme groups below are the full menu; this is the committed *order* for
the next stretch. Ranking favours finishing the current create/edit + calendar
arc before opening new fronts, then cheap-relative-to-value items and ones that
unblock a later item. Order is a plan, not a contract — revisit after each lands.
**Tier 1 — finish the current arc (create/edit + calendars)**
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
above)*
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
5. **Duplicate event** *(next)* — detail action → prefilled create form; near-free on the tap-to-create prefill infra
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
### Settings redesign & restructure *(shipped v2.3.0)*
The original scope below is kept as a record; the implementation expanded from a
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
The settings screen has grown into a flat vertical scroll of divider-separated
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
will keep accreting rows (per-event-color defaults, default reminder, more
calendar entries are all queued). It needs structure before it gets unwieldy.
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
becomes a category list; each category opens its own destination. More
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
existing Calendars row, which already navigates out to its own screen.
Structure — top-level settings list → category destinations:
- **Appearance** → theme, dynamic colour, week start
- **Event form** → the 6 default-field toggles + the hint text
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
peer category row, no change to that screen
- **Language** → single control; keep as a top-level row that opens an
OptionCard directly (a whole sub-screen for one choice is overkill)
- **About** → kept inline on the top-level list as a card (read-only info,
not worth a navigation hop). Card layout, top → bottom:
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
as a subtitle beneath the name
- **Action buttons** (small, button-styled, sit in a row):
- **Source** — Gitea logo, opens the repo (`about_source_url`)
- **License** — opens the LICENSE file on Gitea
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
- **Version** — small version number at the bottom of the card
Scope:
- **Navigation** — add the settings sub-screen destinations alongside the
existing settings/calendars routes in `CalendarHost`; back pops to the
settings list (mind the existing `BackHandler` that guards against falling
through to the activity).
- **Fix the dialog-pattern violation** — theme, week-start and language use
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
(radio/dropdown/text-list dialogs are banned, see
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
- **Visual pass** — top-level category rows with leading icons; consistent
spacing and row affordances aligned with the event-form card design system.
Out of scope (no new settings *features* here) — this is a structure + style
pass on the existing controls; new toggles ride in with their own features.
**Tier 2 — navigation & daily-driver completeness**
5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap
6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget
**Tier 3 — platform reach (depends on Tier 2)**
7. Home-screen widget — built on the agenda data source from #6
8. App shortcuts (launcher long-press → New event); cheap, optional quick-settings tile
**Tier 4 — interop & bigger-ticket**
9. Share event as .ics + receive/open .ics into a prefilled create form
10. Default reminder applied to new events; then snooze/dismiss notification actions
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- 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
- Move event to another calendar — sync-adapter minefield (copy+delete model)
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
whether drag-drop (#11) jumps ahead given its daily-driver impact.
## Navigation & views
- ~~Tap an empty slot in day/week → create form prefilled with that
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
not added — single tap covers it)
- Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1)
- Pinch-to-zoom time scale in day/week - Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)*
Reminders, round two: ## Event editing & creation
- Snooze + dismiss actions on the notification (snooze needs an exact-alarm/WorkManager decision)
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
dialog) — big-ticket, own slice
- Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
*(next)* — chosen to follow the in-progress tap-to-create + calendar
management work: reuses the color-picker component and palette plumbing
being built for local calendar management, and finishes the create/edit
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
## Calendars & accounts
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0**
name + color + description; rename / recolor / delete the calendars the app
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
synced calendars, open the app the calendar actually came from based on
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
…); fall back to system account/sync settings. Plus an "add account"
entry into system Accounts. Honest boundary for remote calendars.
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
client: **INTERNET permission, credential storage, the full server
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
intent to delegate the create to it. Cosmetic local edits (color/name)
to an existing synced row are possible but don't propagate to the server
and may be overwritten on next sync — not promised. Same explicit
go/no-go gate as the OSM/INTERNET item below.
- Move event to another calendar (copy+delete model with a consequences
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
## Reminders, round two
- Snooze + dismiss actions on the notification (snooze needs an
exact-alarm / WorkManager decision)
- Settings default reminder applied to new events - Settings default reminder applied to new events
Event niceties: ## Sharing & interop
- Duplicate event (detail action → prefilled create form)
- Per-event color (`Events.EVENT_COLOR`, OptionCard picker in the form)
- Share event as .ics + open/receive .ics into a prefilled create form (front-runs v3 ICS import)
Small delights: - Share event as .ics + open/receive .ics into a prefilled create form
(front-runs the import below)
- ICS file import (drag-and-drop) *(was v3.0, optional)*
## Platform & launchers
- Home-screen widget *(was v3.0)*
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile - App shortcuts (launcher long-press → New event), maybe a quick-settings tile
- Jump to date (un-cut from V1 — drawer date picker)
Consciously rejected: travel time / weather / smart suggestions (network, ## Locations & People *(go/no-go, captured 2026-06-11)*
core-promise conflict), natural-language quick entry (high effort,
locale-fragile, prefilled form already covers fast entry).
## Idea backlog — Locations & People (captured 2026-06-11, undecided)
Beyond classic calendar-client scope; discussed, deliberately not planned Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet: in detail yet:
@@ -163,3 +329,10 @@ in detail yet:
- **Attendee editing / invites from contacts** — own milestone; writing - **Attendee editing / invites from contacts** — own milestone; writing
`Attendees` rows touches sync-adapter invitation behavior (Google vs `Attendees` rows touches sync-adapter invitation behavior (Google vs
DAVx5 differ). DAVx5 differ).
## Consciously rejected
- Travel time / weather / smart suggestions (network, core-promise conflict)
- Natural-language quick entry (high effort, locale-fragile; the prefilled
form already covers fast entry)
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)

View File

@@ -1,13 +1,15 @@
# Calendula — Current State # Calendula — Current State
*Last updated: 2026-06-11* *Last updated: 2026-06-17*
## Status ## Status
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11. **Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
**Phase:** between milestones. Next: v3.0 (power-user features) and the v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
go/no-go on the "Locations & People" idea backlog (`ROADMAP.md`). Docs **Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed). calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
the calendar manager and the navigation drawer) both shipped 2026-06-16. The
backlog is now organised by theme in `ROADMAP.md`.
## Progress ## Progress
@@ -71,10 +73,56 @@ pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed).
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
refreshed, fastlane screenshots DE+EN captured on-device refreshed, fastlane screenshots DE+EN captured on-device
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
continuous multi-day bars; navigation-drawer View section
(Month/Week/Day); cursor-jump fix in event text fields
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
(prefilled with that day + tapped hour, snapped to the hour); local
calendar management in a full-screen editor from Settings →
Calendars: create/rename/recolor/delete device-only calendars
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
read-only grouped by account with a per-account "manage in source app"
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub (About card on top → version mark at the foot) with
sliding sub-pages (Appearance / New event form / Notifications); token-
based icon chips; theme/week-start/language pickers migrated from
`DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons,
verbatim path) for the About "Source" button; en+de strings.
- Calendar manager: same collapsing scaffold + grouped rows; shared
`CalendarColorChip` (neutral chip, pastelised calendar glyph).
- Navigation drawer: branded header, grouped View switcher (active view
highlighted via `secondaryContainer`), the filter list restyled to
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
- Donate button on the About card deferred (target still TBD).
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
fallback; this adds the write side and the picker. Palette-backed calendars
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
calendars resets the choice (a key is account-scoped). A settings toggle
("Allow colors on unsupported calendars", off by default) extends the raw
path to synced calendars with no palette, with an honest "may not survive
sync" warning on the picker and in Settings. Color writes flow through
insert / dirty-checked update / occurrence-exception; mapper + form tests.
## Next ## Next
1. Decide the "Locations & People" go/no-go (INTERNET permission question) 1. Monitor the F-Droid build/publish for the v2.4.0 tag
— see `ROADMAP.md` idea backlog 2. Decide the "Locations & People" and "remote calendar create/edit"
2. v3.0 scoping: widget, full-text search, tablet layouts, ICS import, go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
calendar-move 3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
3. Monitor the F-Droid build/publish for the v1.4.0 / v2.0.0 tags agenda view (strategic, backs a future widget). Full ranked sequence in
`ROADMAP.md` → "Near-term sequence".

View File

@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.4.0] — 2026-06-17
### Added
- Per-event colors: give a single event its own color, instead of always
inheriting its calendar's. Add the new "Color" field from "More fields" in
the event form. On calendars that publish their own color set — such as
Google — you pick from that calendar's palette, so the color is stored
with the event and shows correctly on every synced device. On local
calendars you pick from Calendula's palette. "Reset" returns an event to
its calendar's color
- A new "Allow colors on unsupported calendars" setting (New event form,
off by default) extends per-event colors to calendars that publish no
color set of their own (some CalDAV). Such a color is kept on the device
and may be dropped or overwritten on that calendar's next sync — a
limitation of those calendars, called out plainly in the setting and on
the color picker
## [2.3.0] — 2026-06-16
### Changed
- Redesigned Settings around the Material 3 grouped-list pattern: a large
title that collapses into the toolbar as you scroll, category cards on the
main screen, and dedicated sub-pages for Appearance, the new-event form, and
Notifications. The theme, week-start and language pickers now use the app's
standard option-card dialogs instead of dropdown menus
- About moved to the top of Settings as a card — app icon, author, and quick
links to the source code and licence — with the version shown plainly at the
foot of the list
- The Calendars screen now uses the same grouped-card layout and collapsing
title, and each calendar shows a soft pastel-tinted calendar glyph rather
than a plain colour swatch
- Redesigned the navigation drawer to match: a branded header, the
Month / Week / Day switch and your calendars as grouped cards (with the
active view highlighted), and the whole drawer now scrolls as one
## [2.2.0] — 2026-06-16
### Added
- Tap an empty slot in the day or week view to create an event there: the
create form opens prefilled with that day and the tapped hour (snapped to
the hour, one hour long). Tapping an existing event still opens it
- Local calendars: create and manage device-only calendars that live
entirely on this phone — no account, no sync — from a new "Calendars"
screen in Settings. Give each a name, a colour, and an optional
description; rename, recolour, or delete them later. Useful when you want
a calendar without setting up an account
- The Calendars screen also lists your synced calendars (DAVx5, ICSx5, …)
grouped by account, each with a "Manage" button that opens the app the
calendar actually comes from, plus an "Add account" shortcut to the
system account settings. Calendula never touches a synced calendar's
server itself — that stays with its own app
### Changed
- Colour swatches in the calendar editor now preview the soft, pastel tone
a calendar is actually drawn with, instead of a bright raw colour
- The calendar editor reuses the event form's field and button styling for
a consistent look
## [2.1.0] — 2026-06-15 ## [2.1.0] — 2026-06-15
### Added ### Added

View File

@@ -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 = 20100 versionCode = 20400
versionName = "2.1.0" versionName = "2.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -6,6 +6,18 @@
<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" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
intent makes launchable apps visible so we can launch whichever app owns a
calendar account's authenticator. -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application <application
android:name=".CalendulaApp" android:name=".CalendulaApp"
android:allowBackup="true" android:allowBackup="true"

View File

@@ -6,6 +6,7 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.CalendarContract import android.provider.CalendarContract
@@ -13,6 +14,7 @@ import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.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
@@ -36,6 +38,28 @@ interface CalendarDataSource {
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail? fun eventDetail(eventId: Long): EventDetail?
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
* account exposes no palette (most local calendars, some CalDAV) — the
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
* which a synced calendar may drop on its next sync.
*/
fun eventColorPalette(calendarId: Long): List<EventColorOption>
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
* provider keeps the row (a plain insert is rejected for the LOCAL account).
*/
fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
/** Update name, color and description of a local calendar the app owns. */
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
/** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long)
/** Insert a new event; returns the new `Events._ID`. */ /** Insert a new event; returns the new `Events._ID`. */
fun insertEvent(form: EventForm): Long fun insertEvent(form: EventForm): Long
@@ -105,6 +129,76 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList() )?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
/**
* Calendar-row writes must address the provider as a sync adapter and name
* the account in the URI; otherwise inserts/deletes for the LOCAL account
* are silently dropped or only soft-deleted.
*/
private fun localCalendarsUri(): Uri = CalendarContract.Calendars.CONTENT_URI.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
.appendQueryParameter(
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.ACCOUNT_TYPE_LOCAL,
)
.build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
// NAME is the sync-adapter id; DISPLAY_NAME is what the user sees.
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
putDescription(description)
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create local calendar '$name'")
return ContentUris.parseId(uri)
}
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
putDescription(description)
}
val rows = resolver.update(
ContentUris.withAppendedId(localCalendarsUri(), id),
values, null, null,
)
if (rows == 0) throw WriteFailedException("update calendar id=$id")
}
/** Store the description in CAL_SYNC1, or clear it when blank/absent. */
private fun ContentValues.putDescription(description: String?) {
val text = description?.trim().orEmpty()
if (text.isEmpty()) {
putNull(CalendarProjection.DESCRIPTION_COLUMN)
} else {
put(CalendarProjection.DESCRIPTION_COLUMN, text)
}
}
override fun deleteCalendar(id: Long) {
val deleted = resolver.delete(
ContentUris.withAppendedId(localCalendarsUri(), id),
null, null,
)
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis) ContentUris.appendId(this, beginMillis)
@@ -131,6 +225,46 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query(
CalendarContract.Colors.CONTENT_URI,
arrayOf(CalendarContract.Colors.COLOR_KEY, CalendarContract.Colors.COLOR),
CalendarContract.Colors.ACCOUNT_NAME + " = ? AND " +
CalendarContract.Colors.ACCOUNT_TYPE + " = ? AND " +
CalendarContract.Colors.COLOR_TYPE + " = ?",
arrayOf(
account.name,
account.type,
CalendarContract.Colors.TYPE_EVENT.toString(),
),
null,
)?.use { c ->
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?.sortedBy { it.key }
?: emptyList()
}
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
arrayOf(
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.ACCOUNT_TYPE,
),
null, null, null,
)?.use { c ->
if (c.moveToFirst()) {
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
} else {
null
}
}
private data class CalendarAccount(val name: String, val type: String)
override fun insertEvent(form: EventForm): Long { override fun insertEvent(form: EventForm): Long {
val times = form.toWriteTimes(ZoneId.systemDefault()) val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply { val values = ContentValues().apply {
@@ -156,6 +290,13 @@ class AndroidCalendarDataSource @Inject constructor(
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() } form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) } ?.let { put(CalendarContract.Events.DESCRIPTION, it) }
// A null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written.
when {
form.colorKey != null ->
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
}
} }
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values) val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
@@ -425,5 +566,11 @@ class AndroidCalendarDataSource @Inject constructor(
private companion object { private companion object {
const val TAG = "CalendarDataSource" const val TAG = "CalendarDataSource"
/**
* Shared account for every app-created local calendar, so they group
* together (by account) in the filter sheet and calendar manager.
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
} }
} }

View File

@@ -3,14 +3,26 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource( internal fun ColumnReader.toCalendarSource(): CalendarSource {
id = getLong(CalendarProjection.IDX_ID), val accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty()
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME) val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
?: Fallbacks.UNNAMED_CALENDAR, return CalendarSource(
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(), id = getLong(CalendarProjection.IDX_ID),
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(), displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
color = getInt(CalendarProjection.IDX_COLOR), ?: Fallbacks.UNNAMED_CALENDAR,
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0, accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >= accountType = accountType,
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR, color = getInt(CalendarProjection.IDX_COLOR),
) isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
isLocal = isLocal,
// CAL_SYNC1 holds the sync token for synced rows, so only treat it as a
// user description on the local calendars the app owns.
description = if (isLocal) {
getString(CalendarProjection.IDX_DESCRIPTION)?.takeIf { it.isNotBlank() }
} else {
null
},
)
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
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
@@ -12,6 +13,21 @@ interface CalendarRepository {
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail suspend fun eventDetail(eventId: Long): EventDetail
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]).
*/
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
/** Update name, color and description of a local calendar the app owns. */
suspend fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
/** Permanently delete a local calendar the app owns, with all its events. */
suspend fun deleteCalendar(id: Long)
/** 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

View File

@@ -3,6 +3,7 @@ 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.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
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
@@ -70,6 +71,27 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
} }
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) }
override suspend fun createLocalCalendar(
displayName: String,
color: Int,
description: String?,
): Long = withContext(io) {
dataSource.createLocalCalendar(displayName, color, description)
}
override suspend fun updateCalendar(
id: Long,
displayName: String,
color: Int,
description: String?,
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun createEvent(form: EventForm): Long = withContext(io) { override suspend fun createEvent(form: EventForm): Long = withContext(io) {
dataSource.insertEvent(form) dataSource.insertEvent(form)
} }

View File

@@ -46,11 +46,16 @@ internal fun ColumnReader.toEventDetailCore(
// localized placeholder, and the edit form must prefill the true value. // localized placeholder, and the edit form must prefill the true value.
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty() val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { // The event's own colour (null = inherits the calendar's) is kept apart
getInt(EventDetailProjection.IDX_CALENDAR_COLOR) // from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display.
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
null
} else { } else {
getInt(EventDetailProjection.IDX_EVENT_COLOR) getInt(EventDetailProjection.IDX_EVENT_COLOR)
} }
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID) val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
val instance = EventInstance( val instance = EventInstance(
@@ -87,6 +92,8 @@ internal fun ColumnReader.toEventDetailCore(
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)), accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE), eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)), selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
eventColor = eventColor,
eventColorKey = eventColorKey,
) )
} }

View File

@@ -85,6 +85,9 @@ internal fun buildEventUpdateValues(
if (updated.accessLevel != original.accessLevel) { if (updated.accessLevel != original.accessLevel) {
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
} }
if (updated.colorKey != original.colorKey || updated.color != original.color) {
putAll(eventColorColumns(updated.colorKey, updated.color))
}
val timesChanged = updated.start != original.start || val timesChanged = updated.start != original.start ||
updated.end != original.end || updated.end != original.end ||
@@ -134,6 +137,28 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
putAll(eventColorColumns(form.colorKey, form.color))
}
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
* account's palette, so the colour round-trips through sync); a raw [color]
* writes `EVENT_COLOR` and clears any key; "no colour" clears both so the event
* falls back to its calendar's colour. The two are never written together —
* the provider rejects a raw colour on a calendar that publishes a palette,
* which is exactly why palette calendars only ever go through the key.
*/
internal fun eventColorColumns(colorKey: String?, color: Int?): Map<String, Any?> = when {
colorKey != null -> mapOf(CalendarContract.Events.EVENT_COLOR_KEY to colorKey)
color != null -> mapOf(
CalendarContract.Events.EVENT_COLOR_KEY to null,
CalendarContract.Events.EVENT_COLOR to color,
)
else -> mapOf(
CalendarContract.Events.EVENT_COLOR_KEY to null,
CalendarContract.Events.EVENT_COLOR to null,
)
} }
/** /**

View File

@@ -11,8 +11,14 @@ internal object CalendarProjection {
CalendarContract.Calendars.CALENDAR_COLOR, CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE, CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
// CalendarContract has no description column; for the local calendars we
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
// so the mapper only reads it for local calendars).
DESCRIPTION_COLUMN,
) )
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
const val IDX_ID = 0 const val IDX_ID = 0
const val IDX_DISPLAY_NAME = 1 const val IDX_DISPLAY_NAME = 1
const val IDX_ACCOUNT_NAME = 2 const val IDX_ACCOUNT_NAME = 2
@@ -20,6 +26,7 @@ internal object CalendarProjection {
const val IDX_COLOR = 4 const val IDX_COLOR = 4
const val IDX_VISIBLE = 5 const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6 const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7
} }
internal object InstanceProjection { internal object InstanceProjection {
@@ -67,6 +74,7 @@ internal object EventDetailProjection {
CalendarContract.Events.ACCESS_LEVEL, CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS, CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.EVENT_COLOR_KEY,
) )
const val IDX_EVENT_ID = 0 const val IDX_EVENT_ID = 0
@@ -86,6 +94,7 @@ internal object EventDetailProjection {
const val IDX_ACCESS_LEVEL = 14 const val IDX_ACCESS_LEVEL = 14
const val IDX_EVENT_TIMEZONE = 15 const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16 const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17
} }
internal object AttendeeProjection { internal object AttendeeProjection {

View File

@@ -99,6 +99,22 @@ class SettingsPrefs @Inject constructor(
store.edit { it[REMINDERS_ENABLED_KEY] = enabled } store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
} }
/**
* Whether to offer a custom event colour even on calendars that publish no
* colour palette (most local calendars handle it fine; synced calendars
* without a palette — some CalDAV — may drop or overwrite a raw colour on
* their next sync). Defaults to OFF: such calendars hide the colour picker
* until the user opts in, accepting the limitation. Local calendars and
* palette-backed calendars (Google, …) are unaffected by this flag.
*/
val allowColorOnUnsupportedCalendars: Flow<Boolean> = store.data.map { prefs ->
prefs[ALLOW_COLOR_UNSUPPORTED_KEY] ?: false
}
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
store.edit { it[ALLOW_COLOR_UNSUPPORTED_KEY] = enabled }
}
/** /**
* Whether the one-time reminder onboarding step (after the calendar * Whether the one-time reminder onboarding step (after the calendar
* grant) has been shown — also true for users who tapped "not now". * grant) has been shown — also true for users who tapped "not now".
@@ -125,6 +141,8 @@ class SettingsPrefs @Inject constructor(
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
booleanPreferencesKey("allow_color_unsupported_calendars")
internal val DEFAULT_FORM_FIELDS = internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description) setOf(EventFormField.Location, EventFormField.Description)
} }

View File

@@ -30,6 +30,17 @@ data class EventForm(
* those are kept verbatim until the user picks something else. * those are kept verbatim until the user picks something else.
*/ */
val rrule: String? = null, val rrule: String? = null,
/**
* The event's own colour, or null to inherit the calendar's colour.
* [colorKey] is a `Colors.COLOR_KEY` from the calendar account's published
* event palette (Google, some CalDAV) — written as `EVENT_COLOR_KEY` so it
* round-trips through sync. When it is null but [color] is set, [color] is
* a raw ARGB written as `EVENT_COLOR` (local calendars, or synced ones the
* user opted into despite no palette). [color] mirrors the key's swatch when
* [colorKey] is set, so the picker can highlight it.
*/
val colorKey: String? = null,
val color: Int? = null,
) )
/** /**
@@ -43,6 +54,7 @@ enum class EventFormField {
Recurrence, Recurrence,
Availability, Availability,
Visibility, Visibility,
Color,
} }
enum class EventFormProblem { enum class EventFormProblem {
@@ -91,6 +103,11 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
availability = availability, availability = availability,
accessLevel = accessLevel, accessLevel = accessLevel,
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
// The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its
// calendar's colour.
colorKey = eventColorKey,
color = eventColor,
) )
} }
@@ -130,6 +147,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (rrule != null) add(EventFormField.Recurrence) if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability) if (availability != Availability.Busy) add(EventFormField.Availability)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
if (colorKey != null || color != null) add(EventFormField.Color)
} }
fun EventForm.problems(): Set<EventFormProblem> = buildSet { fun EventForm.problems(): Set<EventFormProblem> = buildSet {

View File

@@ -15,6 +15,17 @@ data class CalendarSource(
* subscriptions, birthday calendars and other read-only sources. * subscriptions, birthday calendars and other read-only sources.
*/ */
val canModifyContents: Boolean = false, val canModifyContents: Boolean = false,
/**
* A device-only calendar the app itself owns (`ACCOUNT_TYPE_LOCAL`): it has
* no sync backend, so the app can rename / recolor / delete it. Synced
* calendars (Google, DAVx5, …) are managed in their own source app instead.
*/
val isLocal: Boolean = false,
/**
* Free-text note for a local calendar (stored in `CAL_SYNC1`, which the app
* owns for its own calendars). Always null for synced calendars.
*/
val description: String? = null,
) )
data class EventInstance( data class EventInstance(
@@ -47,8 +58,25 @@ data class EventDetail(
val eventTimezone: String? = null, val eventTimezone: String? = null,
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */ /** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown, val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
/**
* The event's own raw colour (`Events.EVENT_COLOR`), null when the event
* inherits its calendar's colour. Unlike [EventInstance.color] (which
* already folds in the calendar fallback for display) this stays null so
* the edit form can tell "has own colour" from "inherits".
*/
val eventColor: Int? = null,
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
val eventColorKey: String? = null,
) )
/**
* One selectable event colour published by a calendar's account
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
* `COLOR_KEY` written as `EVENT_COLOR_KEY` (so the colour survives sync),
* [argb] is the swatch it renders as.
*/
data class EventColorOption(val key: String, val argb: Int)
data class Attendee( data class Attendee(
val name: String, val name: String,
val email: String?, val email: String?,

View File

@@ -16,6 +16,7 @@ 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.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.day.DayScreen
@@ -86,13 +87,23 @@ fun CalendarHost(
var showSettings by rememberSaveable { mutableStateOf(false) } var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true } val onOpenSettings = { showSettings = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen: // Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out. // [heldCreateIso] keeps the prefill date alive through the slide-out.
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
// when the form is opened from a day/week grid tap; null from the FAB.
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) } var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) } var heldCreateIso by remember { mutableStateOf<String?>(null) }
val onCreateEvent: (LocalDate) -> Unit = { date -> var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
heldCreateIso = date.toString() heldCreateIso = date.toString()
createDateIso = date.toString() createDateIso = date.toString()
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
} }
// Edit form (v1.3) — reuses the detail screen's occurrence key; for // Edit form (v1.3) — reuses the detail screen's occurrence key; for
@@ -162,6 +173,7 @@ fun CalendarHost(
(createDateIso ?: heldCreateIso)?.let { iso -> (createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen( EventEditScreen(
initialDateIso = iso, initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
onClose = { createDateIso = null }, onClose = { createDateIso = null },
onSaved = { createDateIso = null }, onSaved = { createDateIso = null },
) )
@@ -193,7 +205,19 @@ fun CalendarHost(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
SettingsScreen(onBack = { showSettings = false }) SettingsScreen(
onBack = { showSettings = false },
onManageCalendars = { showCalendars = true },
)
}
// Calendar manager — slides over Settings.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(onBack = { showCalendars = false })
} }
} }
} }

View File

@@ -0,0 +1,500 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
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.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor),
* and lists synced calendars read-only with a per-account "manage in the source
* app" deep-link — the app never touches a synced calendar's server. A
* full-screen destination; [onBack] pops it.
*/
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
// a fresh open while still surviving configuration changes within one open.
var editorId by rememberSaveable { mutableStateOf<Long?>(null) }
var editorSession by rememberSaveable { mutableStateOf(0) }
if (editorId != null) {
val editing = calendars.firstOrNull { it.id == editorId }
CalendarEditor(
sessionKey = editorSession,
isNew = editorId == NEW_CALENDAR_ID,
initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
initialDescription = editing?.description.orEmpty(),
onSave = { name, color, description ->
val id = editorId
if (id == null || id == NEW_CALENDAR_ID) {
viewModel.createCalendar(name, color, description)
} else {
viewModel.updateCalendar(id, name, color, description)
}
editorId = null
},
onDelete = {
editorId?.takeIf { it != NEW_CALENDAR_ID }?.let(viewModel::deleteCalendar)
editorId = null
},
onClose = { editorId = null },
)
} else {
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
error = error,
onConsumeError = viewModel::consumeError,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
)
}
}
@Composable
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
error: Boolean,
onConsumeError: () -> Unit,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val writeErrorText = stringResource(R.string.calendars_write_error)
LaunchedEffect(error) {
if (error) {
snackbarHostState.showSnackbar(writeErrorText)
onConsumeError()
}
}
CollapsingScaffold(
title = stringResource(R.string.calendars_title),
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
// Local (device-only) calendars — the calendars the app owns. The
// "Add calendar" entry closes the group as its final row.
SectionHeader(stringResource(R.string.calendars_local_header))
if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty))
}
val localCount = local.size + 1
local.forEachIndexed { index, calendar ->
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, localCount),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.calendars_edit_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
onClick = { onEdit(calendar) },
)
}
GroupedRow(
title = stringResource(R.string.calendars_add),
position = positionOf(local.size, localCount),
leading = { AddAvatar() },
onClick = onAdd,
)
Spacer(Modifier.height(16.dp))
// Synced calendars — read-only, grouped by account, each with a
// per-account "manage in source app" link.
SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint))
synced
.groupBy { it.accountName.ifBlank { it.accountType } }
.forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType)
cals.forEachIndexed { index, calendar ->
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
leading = { CalendarColorChip(calendar.color) },
)
}
}
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.calendars_add_account),
position = Position.Alone,
leading = { AddAvatar() },
onClick = {
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CalendarEditor(
sessionKey: Int,
isNew: Boolean,
initialName: String,
initialColor: Int,
initialDescription: String,
onSave: (name: String, color: Int, description: String?) -> Unit,
onDelete: () -> Unit,
onClose: () -> Unit,
) {
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
BackHandler(onBack = onClose)
Scaffold(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {
TopAppBar(
title = {
Text(
stringResource(
if (isNew) R.string.calendars_new_title
else R.string.calendars_edit_title,
),
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_close),
)
}
},
actions = {
if (!isNew) {
IconButton(onClick = { confirmDelete = true }) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
tint = MaterialTheme.colorScheme.error,
)
}
}
// Filled save button, matching the event editor's top bar.
Button(
onClick = {
onSave(name.trim(), color, description.trim().ifEmpty { null })
},
enabled = name.isNotBlank(),
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.event_edit_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
InlineTextField(
value = name,
onValueChange = { name = it },
placeholder = stringResource(R.string.calendars_name_label),
textStyle = MaterialTheme.typography.titleLarge,
capitalization = KeyboardCapitalization.Sentences,
)
}
EditorCard(
icon = Icons.Default.Palette,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_color_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE,
selected = color,
onSelect = { color = it },
dark = dark,
)
}
EditorCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
InlineTextField(
value = description,
onValueChange = { description = it },
placeholder = stringResource(R.string.calendars_description_hint),
singleLine = false,
minLines = 2,
capitalization = KeyboardCapitalization.Sentences,
)
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
text = {
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
},
confirmButton = {
TextButton(onClick = {
confirmDelete = false
onDelete()
}) {
Text(
stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
}
/** Tonal field card matching the event editor's design (icon + content). */
@Composable
private fun EditorCard(
icon: ImageVector,
iconTint: Color,
iconAtTop: Boolean = false,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier
.padding(top = if (iconAtTop) 2.dp else 0.dp)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { content() }
}
}
}
@Composable
private fun AccountHeader(account: String, accountType: String) {
val context = LocalContext.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = account,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = {
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
}) {
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.calendars_manage_in_app))
}
}
}
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
@Composable
private fun AddAvatar() {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
)
}
/**
* Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission
* needed) handles any sync provider — DAVx5, ICSx5, Nextcloud, … — and a small
* curated map redirects the few cases where the authenticator isn't the app to
* open (Google's authenticator is Play Services, but users want the Calendar
* app). Falls back to the system account settings when nothing launchable is
* found, so the button always lands somewhere sensible.
*/
private fun sourceAppIntent(context: Context, accountType: String): Intent {
val pm = context.packageManager
val candidates = buildList {
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
curatedSourcePackage(accountType)?.let { add(it) }
}
for (pkg in candidates) {
pm.getLaunchIntentForPackage(pkg)?.let { return it }
}
return Intent(Settings.ACTION_SYNC_SETTINGS)
}
/** Preferred app for account types whose authenticator isn't the app to open. */
private fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
else -> null
}

View File

@@ -0,0 +1,71 @@
package de.jeanlucmakiola.calendula.ui.calendars
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.domain.CalendarSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/**
* Backs the calendar manager: lists every calendar (the screen splits them into
* the app's own local calendars and read-only/synced ones) and creates,
* renames, recolors or deletes the local calendars the app owns. Write failures
* flip [error] so the screen can surface a one-shot message.
*/
@HiltViewModel
class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
val calendars: StateFlow<List<CalendarSource>> =
repository.calendars()
.catch { emit(emptyList()) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
fun consumeError() { _error.value = false }
fun createCalendar(displayName: String, color: Int, description: String?) = write {
repository.createLocalCalendar(displayName, color, description)
}
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) = write {
repository.updateCalendar(id, displayName, color, description)
}
fun deleteCalendar(id: Long) = write {
repository.deleteCalendar(id)
}
private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_error.value = true
}
}
}
}

View File

@@ -1,6 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/** /**
* Soften a raw calendar color toward a pastel that fits the active theme. * Soften a raw calendar color toward a pastel that fits the active theme.
@@ -15,3 +29,27 @@ fun pastelize(rawArgb: Int, dark: Boolean): Color {
hsv[2] = if (dark) 0.82f else 0.72f hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv)) return Color(android.graphics.Color.HSVToColor(hsv))
} }
/**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
* visibility filter so they read identically.
*/
@Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = pastelize(color, dark),
modifier = Modifier.size(22.dp),
)
}
}

View File

@@ -1,20 +1,33 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Icons
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -23,17 +36,12 @@ import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
/** /**
* Navigation drawer shared by every top-level calendar screen. * Navigation drawer shared by every top-level calendar screen.
* *
* Visual language (kept deliberately small so sizes don't drift): * Uses the app's grouped-card design system (see [GroupedRow]): a branded
* - Drawer title — `titleLarge` * header, the View switcher as a grouped card (the active view highlighted),
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only * the per-calendar visibility filter (M3) inline, and a pinned Settings row.
* - Nav items (the views, Today / Settings) — Material `NavigationDrawerItem` * The "View" section mirrors the top-bar switcher pill — tapping a view here
* (`labelLarge` label + a single 24dp leading icon) * selects it (and closes the drawer) rather than cycling. The host screen owns
* * the drawer state.
* The "View" section mirrors the top-bar switcher pill: tapping a view here
* selects it (and closes the drawer) rather than cycling. Also hosts the
* per-calendar visibility filter (M3) inline — the calendar list with its
* checkboxes lives here rather than in a separate sheet — plus a Settings
* entry (M4). The host screen owns the drawer state.
*/ */
@Composable @Composable
fun CalendarDrawer( fun CalendarDrawer(
@@ -42,46 +50,75 @@ fun CalendarDrawer(
onSettings: () -> Unit, onSettings: () -> Unit,
) { ) {
ModalDrawerSheet { ModalDrawerSheet {
Column(Modifier.fillMaxHeight()) { // The whole sidebar scrolls as one — header, views, the calendar filter
Text( // and Settings all flow in a single scroll container.
text = stringResource(R.string.app_name), Column(
style = MaterialTheme.typography.titleLarge, Modifier
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), .fillMaxHeight()
) .verticalScroll(rememberScrollState()),
HorizontalDivider() ) {
DrawerHeader()
DrawerSectionHeader(stringResource(R.string.view_section)) DrawerSectionHeader(stringResource(R.string.view_section))
IMPLEMENTED_VIEWS.forEach { view -> IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
NavigationDrawerItem( GroupedRow(
icon = { Icon(view.icon, contentDescription = null) }, title = stringResource(view.labelRes),
label = { Text(stringResource(view.labelRes)) }, position = positionOf(index, IMPLEMENTED_VIEWS.size),
selected = view == currentView, selected = view == currentView,
minHeight = 56.dp,
leading = { Icon(view.icon, contentDescription = null) },
onClick = { onSelectView(view) }, onClick = { onSelectView(view) },
modifier = Modifier.padding(horizontal = 12.dp),
) )
} }
Spacer(Modifier.height(8.dp))
HorizontalDivider()
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack Spacer(Modifier.height(16.dp))
// between the top actions and the pinned Settings entry.
DrawerSectionHeader(stringResource(R.string.filter_title)) DrawerSectionHeader(stringResource(R.string.filter_title))
CalendarFilterList(modifier = Modifier.weight(1f)) CalendarFilterList()
HorizontalDivider() Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(8.dp)) GroupedRow(
NavigationDrawerItem( title = stringResource(R.string.month_action_settings),
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, position = Position.Alone,
label = { Text(stringResource(R.string.month_action_settings)) }, minHeight = 56.dp,
selected = false, leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
onClick = onSettings, onClick = onSettings,
modifier = Modifier.padding(horizontal = 12.dp),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
} }
} }
/** Branded header: the app-icon chip beside the app name. */
@Composable
private fun DrawerHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(14.dp))
.background(colorResource(R.color.ic_launcher_background)),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.requiredSize(66.dp),
)
}
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
)
}
}
/** Top-level grouping label in the drawer. Text only, so it never reads as a /** Top-level grouping label in the drawer. Text only, so it never reads as a
* tappable nav item. */ * tappable nav item. */
@Composable @Composable

View File

@@ -0,0 +1,82 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* A wrapping row of round colour swatches; the one matching [selected] is
* ringed and checked. Shared by the calendar editor and the event-colour
* picker so both pick a colour the same way. Swatches render through
* [pastelize] — the softened colour the app actually paints, not the raw hue.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ColorSwatchRow(
colors: List<Int>,
selected: Int?,
onSelect: (Int) -> Unit,
dark: Boolean,
modifier: Modifier = Modifier,
) {
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
colors.forEach { argb ->
val isSelected = argb == selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(vertical = 4.dp)
.size(40.dp)
.clip(CircleShape)
.background(pastelize(argb, dark))
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
.clickable { onSelect(argb) },
) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
0xFFD50000, // red
0xFFE67C00, // orange
0xFFF6BF26, // amber
0xFF33B679, // green
0xFF0B8043, // dark green
0xFF039BE5, // blue
0xFF3F51B5, // indigo
0xFF8E24AA, // purple
0xFF616161, // graphite
).map { it.toInt() }

View File

@@ -0,0 +1,191 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Position of a row within a grouped list, after the Android-15 settings
* pattern: a run of rows shares one rounded container, with full corners at the
* group's outer edges and small corners between, separated by small gaps.
*/
enum class Position { Top, Middle, Bottom, Alone }
/** Maps an index within a group of [count] rows to its [Position]. */
fun positionOf(index: Int, count: Int): Position = when {
count <= 1 -> Position.Alone
index == 0 -> Position.Top
index == count - 1 -> Position.Bottom
else -> Position.Middle
}
/**
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
* whose title shrinks into the bar (next to the back button) as the content
* scrolls. Content is a scrollable column that feeds the toolbar via nested
* scroll. Used by Settings and the calendar manager so they share one shell.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollapsingScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(onBack = onBack)
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
actions = actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
},
snackbarHost = snackbarHost,
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = 24.dp),
content = content,
)
}
}
/**
* 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
* rounded card). Corners round further on press. A null [onClick] makes the
* row non-interactive (e.g. read-only entries).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedRow(
title: String,
position: Position,
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val shape = when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val itemColors = if (selected) {
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
val item: @Composable () -> Unit = {
ListItem(
headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } },
leadingContent = leading,
trailingContent = trailing,
colors = itemColors,
modifier = Modifier.heightIn(min = minHeight),
)
}
val base = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
Surface(
onClick = onClick,
color = containerColor,
shape = shape,
interactionSource = interaction,
modifier = base,
) { item() }
} else {
Surface(color = containerColor, shape = shape, modifier = base) { item() }
}
}

View File

@@ -0,0 +1,74 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The app's borderless text input: no underline, no outline, just the tonal
* card behind it. This is the standard input across the app — we deliberately
* don't use Material's outlined/filled text fields, so anything that takes text
* (the event form, the calendar manager, dialogs) uses this inside a tonal
* [androidx.compose.material3.Surface].
*/
@Composable
fun InlineTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
// Clearly fainter than typed text, so a hint never reads as
// prefilled content.
Text(
text = placeholder,
style = resolvedStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
modifier = modifier,
)
}

View File

@@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -104,7 +105,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialDateIso: String? = null, initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(), viewModel: DayViewModel = hiltViewModel(),
@@ -185,7 +186,7 @@ fun DayScreen(
todayVisible = !isOnToday, todayVisible = !isOnToday,
todayText = stringResource(R.string.day_today_action), todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday, onToday = jumpToToday,
onCreate = { onCreateEvent(date) }, onCreate = { onCreateEvent(date, null) },
) )
}, },
) { innerPadding -> ) { innerPadding ->
@@ -197,6 +198,7 @@ fun DayScreen(
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize(), .fillMaxSize(),
@@ -214,6 +216,7 @@ private fun DayContent(
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -282,6 +285,7 @@ private fun DayContent(
scrollState = scrollState, scrollState = scrollState,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt,
) )
} }
} }
@@ -294,6 +298,7 @@ private fun DaySuccess(
scrollState: ScrollState, scrollState: ScrollState,
allDayHeight: Dp, allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events, // All-day strip collapses to nothing when the day has no all-day events,
@@ -309,7 +314,12 @@ private fun DaySuccess(
// Breathing room between the (colour-shifting) top section and the // Breathing room between the (colour-shifting) top section and the
// scrolling timeline below. // scrolling timeline below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick) Timeline(
state = state,
scrollState = scrollState,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
} }
} }
@@ -427,6 +437,7 @@ private fun Timeline(
state: DayUiState.Success, state: DayUiState.Success,
scrollState: ScrollState, scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) { ) {
val totalHeight = HOUR_HEIGHT * 24 val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
@@ -474,7 +485,9 @@ private fun Timeline(
DayColumnCard( DayColumnCard(
blocks = state.timed, blocks = state.timed,
dark = dark, dark = dark,
date = state.date,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(totalHeight), .height(totalHeight),
@@ -488,9 +501,12 @@ private fun Timeline(
private fun DayColumnCard( private fun DayColumnCard(
blocks: List<TimedBlock>, blocks: List<TimedBlock>,
dark: Boolean, dark: Boolean,
date: LocalDate,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
Card( Card(
// Plain rectangular column — the soft corners come from the outer // Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -500,7 +516,19 @@ private fun DayColumnCard(
), ),
modifier = modifier, modifier = modifier,
) { ) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
onCreateAt(date, hour * 60)
}
},
) {
val colWidth = maxWidth val colWidth = maxWidth
blocks.forEach { block -> blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount val laneWidth = colWidth / block.laneCount

View File

@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
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.Place 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
@@ -102,6 +103,7 @@ import de.jeanlucmakiola.calendula.R
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
import de.jeanlucmakiola.calendula.domain.EventColorOption
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
@@ -110,6 +112,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
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.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -147,6 +152,7 @@ fun EventEditScreen(
onClose: () -> Unit, onClose: () -> Unit,
onSaved: () -> Unit, onSaved: () -> Unit,
editKey: LongArray? = null, editKey: LongArray? = null,
initialStartMinutes: Int? = null,
viewModel: EventEditViewModel = hiltViewModel(), viewModel: EventEditViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(initialDateIso, editKey) { LaunchedEffect(initialDateIso, editKey) {
@@ -159,7 +165,7 @@ fun EventEditScreen(
} 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) viewModel.openNew(date, initialStartMinutes)
} }
} }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -412,6 +418,7 @@ private fun EventEditContent(
var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) }
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) }
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
@@ -421,6 +428,16 @@ private fun EventEditContent(
?: MaterialTheme.colorScheme.primary ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp
// Per-event colour applicability for the resolved calendar:
// - palette calendars (Google, …) and local calendars always support it;
// - synced calendars with no palette only when the user opted in, and even
// then the colour may not survive the calendar's next sync (the warning).
val isLocalCalendar = selectedCalendar?.isLocal == true
val colorSupported = state.colorPalette.isNotEmpty() || isLocalCalendar ||
state.allowColorOnUnsupportedCalendars
val colorSyncRisk = state.colorPalette.isEmpty() && !isLocalCalendar &&
state.allowColorOnUnsupportedCalendars
Column( Column(
modifier = modifier modifier = modifier
// Shrink the scroll viewport by the keyboard instead of letting // Shrink the scroll viewport by the keyboard instead of letting
@@ -690,6 +707,67 @@ private fun EventEditContent(
} }
} }
OptionalFormSection(visible = EventFormField.Color in state.visibleFields) {
Spacer(Modifier.height(gap))
// The swatch the event will paint with: its own colour, else the
// calendar's. The Palette icon takes that colour as a preview.
val swatch = form.color ?: selectedCalendar?.color
EditCard(
icon = Icons.Default.Palette,
iconContentDescription = stringResource(R.string.event_edit_color),
iconTint = if (colorSupported && swatch != null) {
pastelize(swatch, dark)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
onClick = { showColorPicker = true }.takeIf { colorSupported },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(
when {
!colorSupported -> R.string.event_edit_color_unsupported
form.color != null -> R.string.event_edit_color_custom
else -> R.string.event_edit_color_default
},
),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(
if (colorSupported) {
R.string.event_edit_color
} else {
R.string.event_edit_color_unsupported_hint
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (colorSyncRisk) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(R.string.event_edit_color_sync_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (colorSupported) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
TextButton( TextButton(
@@ -777,6 +855,28 @@ private fun EventEditContent(
) )
} }
if (showColorPicker) {
ColorPickerDialog(
palette = state.colorPalette,
selected = form.color,
hasExplicitColor = form.color != null,
syncWarning = colorSyncRisk,
onPickKey = { key, argb ->
viewModel.setColorKey(key, argb)
showColorPicker = false
},
onPickRaw = { argb ->
viewModel.setColorRaw(argb)
showColorPicker = false
},
onClear = {
viewModel.clearColor()
showColorPicker = false
},
onDismiss = { showColorPicker = false },
)
}
if (showFieldPicker) { if (showFieldPicker) {
FieldPickerDialog( FieldPickerDialog(
hiddenFields = state.hiddenFields, hiddenFields = state.hiddenFields,
@@ -1292,6 +1392,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
} }
private fun fieldIcon(field: EventFormField): ImageVector = when (field) { private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
@@ -1301,6 +1402,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette
} }
/** /**
@@ -1334,6 +1436,72 @@ private fun VisibilityPickerDialog(
) )
} }
/**
* Event-colour picker: just the swatches. A non-empty [palette] (the calendar
* account's published colours) picks by key so the colour round-trips through
* sync; otherwise the app's own palette writes a raw colour, with a
* [syncWarning] when that calendar may not keep it. The "Reset" button (shown
* only once a colour is set) drops back to the calendar's own colour.
*/
@Composable
private fun ColorPickerDialog(
palette: List<EventColorOption>,
selected: Int?,
hasExplicitColor: Boolean,
syncWarning: Boolean,
onPickKey: (String, Int) -> Unit,
onPickRaw: (Int) -> Unit,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_color)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (palette.isNotEmpty()) {
ColorSwatchRow(
colors = palette.map { it.argb },
selected = selected,
onSelect = { argb ->
palette.firstOrNull { it.argb == argb }
?.let { onPickKey(it.key, it.argb) }
},
dark = dark,
)
} else {
ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE,
selected = selected,
onSelect = onPickRaw,
dark = dark,
)
if (syncWarning) {
Text(
text = stringResource(R.string.event_edit_color_sync_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
dismissButton = if (hasExplicitColor) {
{
TextButton(onClick = onClear) {
Text(stringResource(R.string.event_edit_color_reset))
}
}
} else {
null
},
)
}
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) { private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
AccessLevel.Default -> Icons.Default.Tune AccessLevel.Default -> Icons.Default.Tune
AccessLevel.Public -> Icons.Default.Public AccessLevel.Public -> Icons.Default.Public
@@ -1436,8 +1604,9 @@ private fun EditCard(
} }
/** /**
* Borderless text input used inside the cards (and as the headline title) * Borderless text input used inside the cards (and as the headline title).
* no underline, no outline, just the card's tonal surface behind it. * Thin wrapper over the shared [InlineTextField] so the form and the rest of
* the app share one input style.
*/ */
@Composable @Composable
private fun InlineField( private fun InlineField(
@@ -1452,36 +1621,15 @@ private fun InlineField(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
) { ) {
val resolvedStyle = textStyle.copy( InlineTextField(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
textStyle = resolvedStyle, placeholder = placeholder,
modifier = modifier,
textStyle = textStyle,
singleLine = singleLine, singleLine = singleLine,
minLines = minLines, minLines = minLines,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType), keyboardType = keyboardType,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
// Clearly fainter than typed text, so a hint (e.g. the
// "10" in the reminder amount) never reads as prefilled.
Text(
text = placeholder,
style = resolvedStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
modifier = modifier,
) )
} }

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.edit package de.jeanlucmakiola.calendula.ui.edit
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm 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
@@ -33,6 +34,18 @@ data class EventEditUiState(
* then drops "only this event" (an exception row can't carry a rule). * then drops "only this event" (an exception row can't carry a rule).
*/ */
val recurrenceChanged: Boolean = false, val recurrenceChanged: Boolean = false,
/**
* The event-colour palette the resolved target calendar publishes; empty
* when it exposes none. Non-empty → the colour picker offers these swatches
* (written as a key, sync-safe); empty → see [colorMode].
*/
val colorPalette: List<EventColorOption> = emptyList(),
/**
* Whether the user has opted into custom colours on calendars that publish
* no palette (a synced one may then drop the colour on sync). Mirrors the
* settings flag; ignored for local and palette-backed calendars.
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
) )
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */ /** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */

View File

@@ -12,6 +12,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.EditSnapshot import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
@@ -19,12 +20,17 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
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.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -98,19 +104,44 @@ class EventEditViewModel @Inject constructor(
val writable: List<CalendarSource>, val writable: List<CalendarSource>,
val lastUsed: Long?, val lastUsed: Long?,
val defaultFields: Set<EventFormField>, val defaultFields: Set<EventFormField>,
val allowColorOnUnsupported: Boolean,
) )
/** Writable calendars — the only valid event targets. */
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) }
/** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine(
_form.map { it?.calendarId },
writableCalendars,
prefs.lastUsedCalendarId,
) { picked, writable, lastUsed ->
picked
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull()?.id
}.distinctUntilChanged()
/** The resolved calendar's published event palette, refetched when it changes. */
@OptIn(ExperimentalCoroutinesApi::class)
private val colorPalette: Flow<List<EventColorOption>> = resolvedCalendarId
.flatMapLatest { id ->
flow { emit(id?.let { repository.eventColorPalette(it) }.orEmpty()) }
}
.flowOn(io)
val state: StateFlow<EventEditUiState?> = combine( val state: StateFlow<EventEditUiState?> = combine(
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs), combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
combine( combine(
repository.calendars() writableCalendars,
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) },
prefs.lastUsedCalendarId, prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields, settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars,
::ExternalInputs, ::ExternalInputs,
).flowOn(io), ).flowOn(io),
) { local, external -> colorPalette,
) { local, external, palette ->
val form = local.form ?: return@combine null val form = local.form ?: return@combine null
val resolvedId = form.calendarId val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
@@ -129,6 +160,8 @@ class EventEditViewModel @Inject constructor(
// the scope dialog drops "only this event" after a rule change. // the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null && recurrenceChanged = local.editTarget != null &&
resolved.rrule != local.editTarget.original.rrule, resolved.rrule != local.editTarget.original.rrule,
colorPalette = palette,
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
) )
} }
.stateIn( .stateIn(
@@ -138,21 +171,26 @@ class EventEditViewModel @Inject constructor(
) )
/** /**
* Initialise a fresh form for a new event on [date]. No-op when a form is * Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
* already open, so user input survives configuration changes; [reset] * from midnight) anchors the start when the form is opened by tapping a slot
* clears it when the screen closes. * in the day/week grid; without it the default is the next full hour (today)
* or 09:00 (any other day). No-op when a form is already open, so user input
* survives configuration changes; [reset] clears it when the screen closes.
*/ */
fun openNew(date: LocalDate) { fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return if (_form.value != null) return
val zone = TimeZone.currentSystemDefault() val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now() val now = Clock.System.now()
val start = if (date == now.toLocalDateTime(zone).date) { val start = when {
// Today: the next full hour (may roll into tomorrow before midnight). startMinutes != null ->
val hourMillis = 3_600_000L LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis date == now.toLocalDateTime(zone).date -> {
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) // Today: the next full hour (may roll into tomorrow before midnight).
} else { val hourMillis = 3_600_000L
LocalDateTime(date, LocalTime(9, 0)) val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
}
else -> LocalDateTime(date, LocalTime(9, 0))
} }
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone) 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)
@@ -202,10 +240,25 @@ class EventEditViewModel @Inject constructor(
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) }
fun setCalendar(id: Long) = update { it.copy(calendarId = id) }
/**
* Switching calendars drops any chosen colour: a palette key is
* account-scoped, and a raw colour may be invalid on the new calendar.
* The event falls back to the new calendar's colour until re-picked.
*/
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
fun 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) }
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
/** Clear the colour so the event inherits its calendar's. */
fun clearColor() = update { it.copy(colorKey = null, color = null) }
/** 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) }

View File

@@ -1,25 +1,17 @@
package de.jeanlucmakiola.calendula.ui.filter package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -28,7 +20,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.positionOf
/** /**
* Calendar-visibility filter (M3), rendered inline in the navigation drawer. * Calendar-visibility filter (M3), rendered inline in the navigation drawer.
@@ -53,67 +47,44 @@ fun CalendarFilterList(
} }
} }
/**
* Renders as a plain (non-scrolling) [Column] so it flows inside the drawer's
* single scroll container — the whole sidebar scrolls as one. Calendar counts
* are small, so a lazy list isn't needed.
*/
@Composable @Composable
private fun FilterList( private fun FilterList(
groups: List<AccountGroup>, groups: List<AccountGroup>,
onSetVisible: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dark = isSystemInDarkTheme() Column(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
groups.forEach { group -> groups.forEach { group ->
item(key = "header-${group.account}") { Text(
Text( text = group.account,
text = group.account, style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp), )
) group.calendars.forEachIndexed { index, cal ->
} GroupedRow(
items(group.calendars, key = { it.id }) { cal -> title = cal.displayName,
CalendarToggleRow( position = positionOf(index, group.calendars.size),
row = cal, minHeight = 56.dp,
dark = dark, leading = { CalendarColorChip(cal.color) },
onCheckedChange = { onSetVisible(cal.id, it) }, trailing = {
Checkbox(
checked = cal.visible,
onCheckedChange = { onSetVisible(cal.id, it) },
)
},
onClick = { onSetVisible(cal.id, !cal.visible) },
) )
} }
} }
} }
} }
@Composable
private fun CalendarToggleRow(
row: CalendarRow,
dark: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(14.dp)
.background(pastelize(row.color, dark), CircleShape),
)
Text(
text = row.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Checkbox(
checked = row.visible,
onCheckedChange = onCheckedChange,
)
}
}
@Composable @Composable
private fun FilterLoading(modifier: Modifier = Modifier) { private fun FilterLoading(modifier: Modifier = Modifier) {
Column( Column(

View File

@@ -86,7 +86,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(), viewModel: MonthViewModel = hiltViewModel(),
) { ) {
@@ -166,6 +166,7 @@ fun MonthScreen(
onCreateEvent( onCreateEvent(
if (isOnCurrentMonth) today if (isOnCurrentMonth) today
else LocalDate(month.year, month.month, 1), else LocalDate(month.year, month.month, 1),
null,
) )
}, },
) )

View File

@@ -1,263 +1,456 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest import android.Manifest
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 androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.HorizontalDivider 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.OutlinedButton
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.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.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.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
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 de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
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.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, EventForm, Notifications }
/** /**
* Settings (M4) — appearance (theme, dynamic colour, week start), language, * Token-based accent for a leading icon chip (container / on-container pair).
* and an about section. A full-screen destination; [onBack] pops it. * Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
* to theme, dark mode and dynamic colour.
*/
private enum class ChipAccent { Neutral, Primary, Tertiary }
/**
* Settings (M4), restructured in v2.3 into a category hub with sub-screens.
* Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the
* grouped-row card system. Calendars opens the separate manager hoisted in
* [CalendarHost]; Language opens an inline OptionCard dialog; About is a card
* at the top. A full-screen destination; [onBack] pops it.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onManageCalendars: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
val slideSpec = rememberCalendarSlideSpec()
// Intercept the system back button/gesture — without this it falls through Box(
// to the activity and closes the app instead of returning to the calendar.
BackHandler { onBack() }
Scaffold(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
topBar = { ) {
TopAppBar( SettingsHub(
title = { Text(stringResource(R.string.settings_title)) }, onBack = onBack,
navigationIcon = { onOpenSection = { section = it },
IconButton(onClick = onBack) { onManageCalendars = onManageCalendars,
Icon( )
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back), AnimatedVisibility(
) visible = section == SettingsSection.Appearance,
} enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
}, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) ) {
}, AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
) { innerPadding -> }
AnimatedVisibility(
visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.Notifications,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
}
}
// ---------------------------------------------------------------------------
// Hub
// ---------------------------------------------------------------------------
@Composable
private fun SettingsHub(
onBack: () -> Unit,
onOpenSection: (SettingsSection) -> Unit,
onManageCalendars: () -> Unit,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_section_appearance),
summary = stringResource(R.string.settings_appearance_subtitle),
position = Position.Top,
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Appearance) },
)
GroupedRow(
title = stringResource(R.string.settings_section_event_form),
summary = stringResource(R.string.settings_event_form_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.EventForm) },
)
GroupedRow(
title = stringResource(R.string.settings_section_notifications),
summary = stringResource(R.string.settings_notifications_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Notifications) },
)
GroupedRow(
title = stringResource(R.string.settings_section_calendars),
summary = stringResource(R.string.settings_manage_calendars_hint),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
onClick = onManageCalendars,
)
LanguageRow(position = Position.Bottom)
AppVersionText()
}
}
@Composable
private fun LanguageRow(position: Position) {
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.current()) }
var showDialog by remember { mutableStateOf(false) }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
position = position,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
onClick = { showDialog = true },
)
if (showDialog) {
OptionPickerDialog(
title = stringResource(R.string.settings_language),
options = LanguagePref.entries,
selected = current,
label = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
)
}
}
@Composable
private fun AboutCard() {
val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url)
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column( Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
SectionHeader(stringResource(R.string.settings_section_appearance))
SettingDropdownRow(
title = stringResource(R.string.settings_theme),
selected = state.themeMode,
options = ThemeMode.entries,
optionLabel = { themeLabel(it) },
onSelect = viewModel::setThemeMode,
)
DynamicColorRow(
checked = state.dynamicColor,
enabled = state.dynamicColorAvailable,
onCheckedChange = viewModel::setDynamicColor,
)
SettingDropdownRow(
title = stringResource(R.string.settings_week_start),
selected = state.weekStart,
options = WeekStartPref.entries,
optionLabel = { weekStartLabel(it) },
onSelect = viewModel::setWeekStart,
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_event_form))
Text(
text = stringResource(R.string.settings_form_fields_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
EventFormField.entries.forEach { field ->
FormFieldRow(
title = stringResource(formFieldLabel(field)),
checked = field in state.defaultFormFields,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_notifications))
RemindersRow(
checked = state.remindersEnabled,
onCheckedChange = viewModel::setRemindersEnabled,
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_language))
LanguageRow()
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_about))
AboutSection()
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun LanguageRow() {
// Setting a locale recreates the activity; mirror the choice locally so the
// dropdown updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.current()) }
SettingDropdownRow(
title = stringResource(R.string.settings_language),
selected = current,
options = LanguagePref.entries,
optionLabel = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
)
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun <T> SettingDropdownRow(
title: String,
selected: T,
options: List<T>,
optionLabel: @Composable (T) -> String,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { expanded = true } .padding(16.dp),
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = title, AppLogo()
style = MaterialTheme.typography.bodyLarge, Spacer(Modifier.width(16.dp))
modifier = Modifier.weight(1f), Column(Modifier.weight(1f)) {
) Text(
Text( text = stringResource(R.string.app_name),
text = optionLabel(selected), style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.bodyMedium, )
color = MaterialTheme.colorScheme.onSurfaceVariant, Text(
) text = stringResource(R.string.settings_about_author),
Icon( style = MaterialTheme.typography.bodyMedium,
Icons.Default.ArrowDropDown, color = MaterialTheme.colorScheme.onSurfaceVariant,
contentDescription = null, )
tint = MaterialTheme.colorScheme.onSurfaceVariant, }
) }
} Spacer(Modifier.height(12.dp))
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { Row(
options.forEach { option -> modifier = Modifier.fillMaxWidth(),
DropdownMenuItem( horizontalArrangement = Arrangement.spacedBy(8.dp),
text = { Text(optionLabel(option)) }, ) {
onClick = { OutlinedButton(
expanded = false onClick = { openUrl(context, sourceUrl) },
onSelect(option) contentPadding = PaddingValues(horizontal = 12.dp),
}, modifier = Modifier.weight(1f),
) ) {
Icon(
painter = painterResource(R.drawable.ic_gitea),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_source))
}
OutlinedButton(
onClick = { openUrl(context, licenseUrl) },
contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f),
) {
Icon(
Icons.Default.Gavel,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_license))
}
} }
} }
} }
} }
/** Plain centred version mark at the foot of the settings list (no card). */
@Composable @Composable
private fun DynamicColorRow( private fun AppVersionText() {
checked: Boolean, val context = LocalContext.current
enabled: Boolean, val versionName = remember {
onCheckedChange: (Boolean) -> Unit, runCatching {
) { context.packageManager.getPackageInfo(context.packageName, 0).versionName
Row( }.getOrNull() ?: ""
}
Text(
text = stringResource(R.string.settings_about_version, versionName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp), .padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically, )
}
/**
* The app icon as a rounded chip: the off-white launcher mark over its slate
* background colour, rendered oversized and clipped to fill the chip the way a
* launcher mask would.
*/
@Composable
private fun AppLogo() {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(colorResource(R.color.ic_launcher_background)),
contentAlignment = Alignment.Center,
) { ) {
Column(Modifier.weight(1f)) { Image(
Text( painter = painterResource(R.drawable.ic_launcher_foreground),
text = stringResource(R.string.settings_dynamic_color), contentDescription = stringResource(R.string.settings_about_logo_desc),
style = MaterialTheme.typography.bodyLarge, modifier = Modifier.requiredSize(108.dp),
color = if (enabled) MaterialTheme.colorScheme.onSurface )
else MaterialTheme.colorScheme.onSurfaceVariant, }
) }
if (!enabled) {
Text( // ---------------------------------------------------------------------------
text = stringResource(R.string.settings_dynamic_color_unavailable), // Sub-screens
style = MaterialTheme.typography.bodySmall, // ---------------------------------------------------------------------------
color = MaterialTheme.colorScheme.onSurfaceVariant,
@Composable
private fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showTheme by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
) {
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = themeLabel(state.themeMode),
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (state.dynamicColorAvailable) {
null
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Middle,
trailing = {
Switch(
checked = state.dynamicColor,
onCheckedChange = viewModel::setDynamicColor,
enabled = state.dynamicColorAvailable,
) )
} },
onClick = if (state.dynamicColorAvailable) {
{ viewModel.setDynamicColor(!state.dynamicColor) }
} else {
null
},
)
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart),
position = Position.Bottom,
onClick = { showWeekStart = true },
)
}
if (showTheme) {
OptionPickerDialog(
title = stringResource(R.string.settings_theme),
options = ThemeMode.entries,
selected = state.themeMode,
label = { themeLabel(it) },
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
}
if (showWeekStart) {
OptionPickerDialog(
title = stringResource(R.string.settings_week_start),
options = WeekStartPref.entries,
selected = state.weekStart,
label = { weekStartLabel(it) },
onSelect = viewModel::setWeekStart,
onDismiss = { showWeekStart = false },
)
}
}
@Composable
private fun EventFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_event_form),
onBack = onBack,
) {
Text(
text = stringResource(R.string.settings_form_fields_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
val fields = EventFormField.entries
fields.forEachIndexed { index, field ->
val checked = field in state.defaultFormFields
GroupedRow(
title = stringResource(formFieldLabel(field)),
position = positionOf(index, fields.size),
trailing = {
Switch(
checked = checked,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
)
},
onClick = { viewModel.setFormFieldDefault(field, !checked) },
)
} }
Switch(
checked = checked, // Per-event colour on calendars that publish no colour set (some
onCheckedChange = onCheckedChange, // CalDAV) — off by default, with the honest caveat that the colour may
enabled = enabled, // not survive their next sync. Local and palette calendars ignore it.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_color_unsupported),
summary = stringResource(R.string.settings_color_unsupported_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.allowColorOnUnsupportedCalendars,
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
)
},
onClick = {
viewModel.setAllowColorOnUnsupportedCalendars(
!state.allowColorOnUnsupportedCalendars,
)
},
) )
} }
} }
@@ -268,134 +461,112 @@ private fun DynamicColorRow(
* the pref is set either way; the OS permission is the real gate. * the pref is set either way; the OS permission is the real gate.
*/ */
@Composable @Composable
private fun RemindersRow( private fun NotificationsScreen(
checked: Boolean, state: SettingsUiState,
onCheckedChange: (Boolean) -> Unit, viewModel: SettingsViewModel,
onBack: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ } ) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
Row( val toggleReminders: (Boolean) -> Unit = { enabled ->
modifier = Modifier viewModel.setRemindersEnabled(enabled)
.fillMaxWidth() val needsPermission = enabled &&
.padding(horizontal = 24.dp, vertical = 12.dp), Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
verticalAlignment = Alignment.CenterVertically, ContextCompat.checkSelfPermission(
) { context, Manifest.permission.POST_NOTIFICATIONS,
Column(Modifier.weight(1f)) { ) != PackageManager.PERMISSION_GRANTED
Text( if (needsPermission) {
text = stringResource(R.string.settings_reminders), launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_reminders_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
Spacer(Modifier.width(16.dp)) }
Switch(
checked = checked, CollapsingScaffold(
onCheckedChange = { enabled -> title = stringResource(R.string.settings_section_notifications),
onCheckedChange(enabled) onBack = onBack,
val needsPermission = enabled && ) {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && GroupedRow(
ContextCompat.checkSelfPermission( title = stringResource(R.string.settings_reminders),
context, Manifest.permission.POST_NOTIFICATIONS, summary = stringResource(R.string.settings_reminders_hint),
) != PackageManager.PERMISSION_GRANTED position = Position.Alone,
if (needsPermission) { trailing = {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS) Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
}
}, },
onClick = { toggleReminders(!state.remindersEnabled) },
) )
} }
} }
@Composable // ---------------------------------------------------------------------------
private fun AboutSection() { // Shared building blocks
val context = LocalContext.current // ---------------------------------------------------------------------------
val versionName = remember {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: ""
}
val sourceUrl = stringResource(R.string.about_source_url)
AboutRow( /**
title = stringResource(R.string.settings_version), * Leading circular icon chip. Colours come from the M3 scheme via a container /
value = versionName, * on-container token pair, so each accent stays correctly paired across theme,
) * dark mode and dynamic colour.
AboutRow( */
title = stringResource(R.string.settings_license), @Composable
value = stringResource(R.string.settings_license_value), private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
) val scheme = MaterialTheme.colorScheme
Row( val (background, iconColor) = when (accent) {
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
}
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .size(40.dp)
.padding(horizontal = 16.dp, vertical = 4.dp), .clip(CircleShape)
verticalAlignment = Alignment.CenterVertically, .background(background),
contentAlignment = Alignment.Center,
) { ) {
Column(Modifier.weight(1f).padding(start = 8.dp)) { Icon(
Text( imageVector = icon,
text = stringResource(R.string.settings_source), contentDescription = null,
style = MaterialTheme.typography.bodyLarge, tint = iconColor,
) modifier = Modifier.size(22.dp),
Text( )
text = sourceUrl.removePrefix("https://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, sourceUrl.toUri())
runCatching { context.startActivity(intent) }
}) {
Text(stringResource(R.string.settings_source_open))
}
} }
} }
/** OptionCard selection dialog — the app's only sanctioned picker style. */
@Composable @Composable
private fun AboutRow(title: String, value: String) { private fun <T> OptionPickerDialog(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
}
@Composable
private fun FormFieldRow(
title: String, title: String,
checked: Boolean, options: List<T>,
onCheckedChange: (Boolean) -> Unit, selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) { ) {
Row( AlertDialog(
modifier = Modifier onDismissRequest = onDismiss,
.fillMaxWidth() title = { Text(title) },
.padding(horizontal = 24.dp, vertical = 8.dp), text = {
verticalAlignment = Alignment.CenterVertically, Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
) { options.forEach { option ->
Text( OptionCard(
text = title, label = label(option),
style = MaterialTheme.typography.bodyLarge, onClick = {
modifier = Modifier.weight(1f), onSelect(option)
) onDismiss()
Switch(checked = checked, onCheckedChange = onCheckedChange) },
} selected = option == selected,
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
runCatching { context.startActivity(intent) }
} }
private fun formFieldLabel(field: EventFormField): Int = when (field) { private fun formFieldLabel(field: EventFormField): Int = when (field) {
@@ -405,6 +576,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
} }
@Composable @Composable

View File

@@ -20,4 +20,9 @@ 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,
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
) )

View File

@@ -24,20 +24,27 @@ class SettingsViewModel @Inject constructor(
val state: StateFlow<SettingsUiState> = val state: StateFlow<SettingsUiState> =
combine( combine(
prefs.themeMode, // combine() only types up to five flows, so the sixth pref folds
prefs.dynamicColor, // into the assembled state in an outer combine.
prefs.weekStart, combine(
prefs.defaultFormFields, prefs.themeMode,
prefs.remindersEnabled, prefs.dynamicColor,
) { theme, dynamic, weekStart, formFields, reminders -> prefs.weekStart,
SettingsUiState( prefs.defaultFormFields,
themeMode = theme, prefs.remindersEnabled,
dynamicColor = dynamic && dynamicColorAvailable, ) { theme, dynamic, weekStart, formFields, reminders ->
dynamicColorAvailable = dynamicColorAvailable, SettingsUiState(
weekStart = weekStart, themeMode = theme,
defaultFormFields = formFields, dynamicColor = dynamic && dynamicColorAvailable,
remindersEnabled = reminders, dynamicColorAvailable = dynamicColorAvailable,
) weekStart = weekStart,
defaultFormFields = formFields,
remindersEnabled = reminders,
)
},
prefs.allowColorOnUnsupportedCalendars,
) { base, allowColor ->
base.copy(allowColorOnUnsupportedCalendars = allowColor)
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
@@ -63,4 +70,8 @@ class SettingsViewModel @Inject constructor(
fun setRemindersEnabled(enabled: Boolean) { fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) } viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
} }
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
}
} }

View File

@@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -112,7 +113,7 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(), viewModel: WeekViewModel = hiltViewModel(),
) { ) {
@@ -194,7 +195,7 @@ fun WeekScreen(
// Anchor on today when it's in view, else the week's first day. // Anchor on today when it's in view, else the week's first day.
val today = Clock.System.now() val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date .toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent(if (isOnCurrentWeek) today else weekStart) onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
}, },
) )
}, },
@@ -207,6 +208,7 @@ fun WeekScreen(
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize(), .fillMaxSize(),
@@ -224,6 +226,7 @@ private fun WeekContent(
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -295,6 +298,7 @@ private fun WeekContent(
scrollState = scrollState, scrollState = scrollState,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt,
) )
} }
} }
@@ -307,6 +311,7 @@ private fun WeekSuccess(
scrollState: ScrollState, scrollState: ScrollState,
allDayHeight: Dp, allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Column( Column(
@@ -320,7 +325,12 @@ private fun WeekSuccess(
// Breathing room between the (colour-shifting) top section and the // Breathing room between the (colour-shifting) top section and the
// scrolling timeline below. // scrolling timeline below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick) Timeline(
state = state,
scrollState = scrollState,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
} }
} }
@@ -533,6 +543,7 @@ private fun Timeline(
state: WeekUiState.Success, state: WeekUiState.Success,
scrollState: ScrollState, scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) { ) {
val totalHeight = HOUR_HEIGHT * 24 val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
@@ -588,7 +599,9 @@ private fun Timeline(
DayColumnCard( DayColumnCard(
blocks = state.timedByDay[day].orEmpty(), blocks = state.timedByDay[day].orEmpty(),
dark = dark, dark = dark,
date = day,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight(), .fillMaxHeight(),
@@ -604,9 +617,12 @@ private fun Timeline(
private fun DayColumnCard( private fun DayColumnCard(
blocks: List<TimedBlock>, blocks: List<TimedBlock>,
dark: Boolean, dark: Boolean,
date: LocalDate,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
Card( Card(
// Plain rectangular columns — the soft corners come from the outer // Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -616,7 +632,18 @@ private fun DayColumnCard(
), ),
modifier = modifier, modifier = modifier,
) { ) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Tap an empty slot to create an event there; taps on event
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
detectTapGestures { offset ->
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
onCreateAt(date, hour * 60)
}
},
) {
val colWidth = maxWidth val colWidth = maxWidth
blocks.forEach { block -> blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount val laneWidth = colWidth / block.laneCount

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gitea brand mark, used on the "Source" button in Settings → About.
Single-path logo from Simple Icons (https://simpleicons.org, CC0),
pathData kept verbatim so Android's PathParser reads the arc flags.
fillColor is a placeholder; the Compose Icon recolours it via tint.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z" />
</vector>

View File

@@ -82,6 +82,15 @@
<string name="event_edit_availability">Verfügbarkeit</string> <string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string> <string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — eigene Terminfarbe -->
<string name="event_edit_color">Farbe</string>
<string name="event_edit_color_default">Kalenderfarbe</string>
<string name="event_edit_color_custom">Eigene Farbe</string>
<string name="event_edit_color_reset">Zurücksetzen</string>
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) --> <!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string> <string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string> <string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
@@ -208,18 +217,46 @@
<string name="settings_week_start_sunday">Sonntag</string> <string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_section_event_form">Termin-Formular</string> <string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string> <string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
<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_section_calendars">Kalender</string>
<string name="settings_manage_calendars">Kalender verwalten</string>
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
<string name="settings_section_language">Sprache</string> <string name="settings_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_german">Deutsch</string>
<string name="settings_language_english">English</string> <string name="settings_language_english">English</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
<string name="settings_section_about">Über</string> <string name="settings_section_about">Über</string>
<string name="settings_version">Version</string>
<string name="settings_license">Lizenz</string> <string name="settings_license">Lizenz</string>
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_source">Quellcode</string> <string name="settings_about_author">von Jean-Luc Makiola</string>
<string name="settings_source_open">Öffnen</string> <string name="settings_about_source">Quellcode</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<!-- Calendar manager -->
<string name="calendars_title">Kalender</string>
<string name="calendars_local_header">Deine Kalender</string>
<string name="calendars_local_empty">Noch keine lokalen Kalender. Lege einen an, um Termine nur auf diesem Gerät zu speichern.</string>
<string name="calendars_add">Kalender hinzufügen</string>
<string name="calendars_synced_header">Synchronisierte Kalender</string>
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
<string name="calendars_manage_in_app">Verwalten</string>
<string name="calendars_add_account">Konto hinzufügen</string>
<string name="calendars_new_title">Neuer Kalender</string>
<string name="calendars_edit_title">Kalender bearbeiten</string>
<string name="calendars_name_label">Name</string>
<string name="calendars_color_label">Farbe</string>
<string name="calendars_description_hint">Beschreibung hinzufügen</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_write_error">Änderung konnte nicht gespeichert werden.</string>
</resources> </resources>

View File

@@ -83,6 +83,15 @@
<string name="event_edit_availability">Availability</string> <string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string> <string name="event_edit_visibility">Visibility</string>
<!-- Event form — per-event color -->
<string name="event_edit_color">Color</string>
<string name="event_edit_color_default">Calendar color</string>
<string name="event_edit_color_custom">Custom color</string>
<string name="event_edit_color_reset">Reset</string>
<string name="event_edit_color_unsupported">Not available for this calendar</string>
<string name="event_edit_color_unsupported_hint">This calendar publishes no color set. You can allow custom colors for such calendars in Settings.</string>
<string name="event_edit_color_sync_warning">This calendar may drop or overwrite the color on its next sync.</string>
<!-- Event form — save conflict (v2.0) --> <!-- Event form — save conflict (v2.0) -->
<string name="event_edit_conflict_title">Event changed elsewhere</string> <string name="event_edit_conflict_title">Event changed elsewhere</string>
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string> <string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
@@ -209,19 +218,48 @@
<string name="settings_week_start_sunday">Sunday</string> <string name="settings_week_start_sunday">Sunday</string>
<string name="settings_section_event_form">New event form</string> <string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string> <string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
<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_section_calendars">Calendars</string>
<string name="settings_manage_calendars">Manage calendars</string>
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
<string name="settings_section_language">Language</string> <string name="settings_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_german">Deutsch</string>
<string name="settings_language_english">English</string> <string name="settings_language_english">English</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_section_about">About</string> <string name="settings_section_about">About</string>
<string name="settings_version">Version</string>
<string name="settings_license">License</string> <string name="settings_license">License</string>
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_source">Source code</string> <string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_source_open">Open</string> <string name="settings_about_source">Source</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula app icon</string>
<!-- Calendar manager -->
<string name="calendars_title">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_add">Add calendar</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_manage_in_app">Manage</string>
<string name="calendars_add_account">Add account</string>
<string name="calendars_new_title">New calendar</string>
<string name="calendars_edit_title">Edit calendar</string>
<string name="calendars_name_label">Name</string>
<string name="calendars_color_label">Color</string>
<string name="calendars_description_hint">Add a description</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_write_error">Couldn\'t save the change.</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>
</resources> </resources>

View File

@@ -14,6 +14,7 @@ class CalendarMapperTest {
color: Int = 0, color: Int = 0,
visible: Int = 1, visible: Int = 1,
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER, accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
description: String? = null,
): MapColumnReader = MapColumnReader( ): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id, CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName, CalendarProjection.IDX_DISPLAY_NAME to displayName,
@@ -22,6 +23,7 @@ class CalendarMapperTest {
CalendarProjection.IDX_COLOR to color, CalendarProjection.IDX_COLOR to color,
CalendarProjection.IDX_VISIBLE to visible, CalendarProjection.IDX_VISIBLE to visible,
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel, CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
CalendarProjection.IDX_DESCRIPTION to description,
) )
@Test @Test
@@ -90,4 +92,35 @@ class CalendarMapperTest {
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE) val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
assertThat(src.toCalendarSource().canModifyContents).isFalse() assertThat(src.toCalendarSource().canModifyContents).isFalse()
} }
@Test
fun `local account type marks the calendar as app-owned`() {
val src = reader(accountType = CalendarContract.ACCOUNT_TYPE_LOCAL).toCalendarSource()
assertThat(src.isLocal).isTrue()
}
@Test
fun `synced account type is not local`() {
val src = reader(accountType = "com.google").toCalendarSource()
assertThat(src.isLocal).isFalse()
}
@Test
fun `local calendar exposes its CAL_SYNC1 description`() {
val src = reader(
accountType = CalendarContract.ACCOUNT_TYPE_LOCAL,
description = "House stuff",
).toCalendarSource()
assertThat(src.description).isEqualTo("House stuff")
}
@Test
fun `synced calendar never exposes CAL_SYNC1 as a description`() {
// CAL_SYNC1 holds the sync token for synced rows — it must not leak as a note.
val src = reader(
accountType = "com.google",
description = """{"type":"SYNC_TOKEN","value":"…"}""",
).toCalendarSource()
assertThat(src.description).isNull()
}
} }

View File

@@ -7,6 +7,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -327,6 +328,65 @@ class CalendarRepositoryImplTest {
} }
} }
@Test
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextCalendarId = 501L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val id = repo.createLocalCalendar(
displayName = "Home",
color = 0xFF33B679.toInt(),
description = "House stuff",
)
assertThat(id).isEqualTo(501L)
assertThat(fake.createdCalendars).containsExactly(
FakeCalendarDataSource.CreatedCalendar("Home", 0xFF33B679.toInt(), "House stuff"),
)
}
@Test
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.updateCalendar(
id = 5L,
displayName = "Renamed",
color = 0xFF039BE5.toInt(),
description = null,
)
assertThat(fake.updatedCalendars).containsExactly(
FakeCalendarDataSource.UpdatedCalendar(5L, "Renamed", 0xFF039BE5.toInt(), null),
)
}
@Test
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteCalendar(id = 7L)
assertThat(fake.deletedCalendarIds).containsExactly(7L)
}
@Test
fun `createLocalCalendar propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("create local calendar 'Home'")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
try {
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("Home")
}
}
@Test @Test
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest { fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {
@@ -341,4 +401,20 @@ class CalendarRepositoryImplTest {
assertThat(expected.message).contains("999") assertThat(expected.message).contains("999")
} }
} }
@Test
fun `eventColorPalette delegates to the data source for the given calendar`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
eventColorPaletteResult = { id ->
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
assertThat(repo.eventColorPalette(7L))
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
assertThat(repo.eventColorPalette(8L)).isEmpty()
}
} }

View File

@@ -20,6 +20,7 @@ class EventDetailMapperTest {
organizer: String? = "x@y", organizer: String? = "x@y",
rrule: String? = null, rrule: String? = null,
eventColor: Any? = null, eventColor: Any? = null,
eventColorKey: String? = null,
calendarColor: Int = 0xFFAABBCC.toInt(), calendarColor: Int = 0xFFAABBCC.toInt(),
dtstart: Long = 1_000_000_000L, dtstart: Long = 1_000_000_000L,
dtend: Long = 1_000_003_600L, dtend: Long = 1_000_003_600L,
@@ -49,6 +50,7 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel, EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone, EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus, EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
) )
private fun attendeeReader( private fun attendeeReader(
@@ -99,6 +101,22 @@ class EventDetailMapperTest {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt()) val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
.toDetail() .toDetail()
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt()) assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
// No own colour: the edit form must see this as "inherits".
assertThat(detail.eventColor).isNull()
assertThat(detail.eventColorKey).isNull()
}
@Test
fun `own event color and key are surfaced apart from the resolved color`() {
val detail = detailReader(
eventColor = 0xFF33B679.toInt(),
eventColorKey = "5",
calendarColor = 0xFF112233.toInt(),
).toDetail()
// Resolved display colour is the event's own, not the calendar fallback.
assertThat(detail!!.instance.color).isEqualTo(0xFF33B679.toInt())
assertThat(detail.eventColor).isEqualTo(0xFF33B679.toInt())
assertThat(detail.eventColorKey).isEqualTo("5")
} }
@Test @Test

View File

@@ -218,4 +218,83 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null) assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null) assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
} }
// --- per-event colour ---
@Test
fun `palette colour writes only the key, never a raw colour`() {
assertThat(eventColorColumns(colorKey = "5", color = 0xFF33B679.toInt()))
.containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "5")
}
@Test
fun `raw colour writes the colour and clears any key`() {
assertThat(eventColorColumns(colorKey = null, color = 0xFF8E24AA.toInt()))
.containsExactly(
CalendarContract.Events.EVENT_COLOR_KEY, null,
CalendarContract.Events.EVENT_COLOR, 0xFF8E24AA.toInt(),
)
}
@Test
fun `no colour clears both columns so the event inherits its calendar`() {
assertThat(eventColorColumns(colorKey = null, color = null))
.containsExactly(
CalendarContract.Events.EVENT_COLOR_KEY, null,
CalendarContract.Events.EVENT_COLOR, null,
)
}
@Test
fun `setting a palette colour on update writes just the key`() {
val original = form()
val values = update(original, original.copy(colorKey = "3", color = 0xFFF6BF26.toInt()))
assertThat(values).containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "3")
}
@Test
fun `setting a raw colour on update writes the colour and a null key`() {
val original = form()
val values = update(original, original.copy(color = 0xFF039BE5.toInt()))
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, 0xFF039BE5.toInt())
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
@Test
fun `clearing a colour on update writes explicit nulls`() {
val original = form().copy(color = 0xFFD50000.toInt())
val values = update(original, original.copy(color = null))
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
@Test
fun `unchanged colour writes no colour columns`() {
val original = form().copy(colorKey = "7", color = 0xFF3F51B5.toInt())
val values = update(original, original.copy(title = "Renamed"))
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `occurrence exception carries the palette key`() {
val values = buildOccurrenceExceptionValues(
form = form().copy(colorKey = "2", color = 0xFFE67C00.toInt()),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, "2")
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `occurrence exception with no colour clears both columns`() {
val values = buildOccurrenceExceptionValues(
form = form(),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
} }

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
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
@@ -14,6 +15,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList() var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() } var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null } var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
/** 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]. */
@@ -26,6 +28,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
val deletedEventIds = mutableListOf<Long>() val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>() val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>() val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
/** Id returned by the next [createLocalCalendar]. */
var nextCalendarId: Long = 500L
data class CreatedCalendar(val displayName: String, val color: Int, val description: String?)
data class UpdatedCalendar(
val id: Long,
val displayName: String,
val color: Int,
val description: String?,
)
val createdCalendars = mutableListOf<CreatedCalendar>()
val updatedCalendars = mutableListOf<UpdatedCalendar>()
val deletedCalendarIds = mutableListOf<Long>()
private val listeners = mutableListOf<() -> Unit>() private val listeners = mutableListOf<() -> Unit>()
@@ -33,6 +47,24 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> = override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis) instancesResult(beginMillis, endMillis)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
writeError?.let { throw it }
createdCalendars += CreatedCalendar(displayName, color, description)
return nextCalendarId
}
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
writeError?.let { throw it }
updatedCalendars += UpdatedCalendar(id, displayName, color, description)
}
override fun deleteCalendar(id: Long) {
writeError?.let { throw it }
deletedCalendarIds += id
}
override fun insertEvent(form: EventForm): Long { override fun insertEvent(form: EventForm): Long {
writeError?.let { throw it } writeError?.let { throw it }

View File

@@ -115,6 +115,8 @@ class EventFormTest {
rowStart: Long = 0L, rowStart: Long = 0L,
rowEnd: Long = 0L, rowEnd: Long = 0L,
attendees: List<Attendee> = emptyList(), attendees: List<Attendee> = emptyList(),
eventColor: Int? = null,
eventColorKey: String? = null,
): EventDetail = EventDetail( ): EventDetail = EventDetail(
instance = EventInstance( instance = EventInstance(
instanceId = 1L, instanceId = 1L,
@@ -134,6 +136,8 @@ class EventFormTest {
reminders = reminders, reminders = reminders,
availability = availability, availability = availability,
accessLevel = accessLevel, accessLevel = accessLevel,
eventColor = eventColor,
eventColorKey = eventColorKey,
) )
@Test @Test
@@ -227,6 +231,7 @@ class EventFormTest {
rrule = "FREQ=DAILY", rrule = "FREQ=DAILY",
availability = Availability.Free, availability = Availability.Free,
accessLevel = AccessLevel.Private, accessLevel = AccessLevel.Private,
color = 0xFFD50000.toInt(),
) )
assertThat(full.populatedFields()).containsExactly( assertThat(full.populatedFields()).containsExactly(
EventFormField.Location, EventFormField.Location,
@@ -235,6 +240,33 @@ class EventFormTest {
EventFormField.Recurrence, EventFormField.Recurrence,
EventFormField.Availability, EventFormField.Availability,
EventFormField.Visibility, EventFormField.Visibility,
EventFormField.Color,
) )
} }
@Test
fun `toEditForm carries a palette colour as key plus swatch`() {
val prefilled = detail(eventColor = 0xFF33B679.toInt(), eventColorKey = "5")
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.colorKey).isEqualTo("5")
assertThat(prefilled.color).isEqualTo(0xFF33B679.toInt())
assertThat(prefilled.populatedFields()).contains(EventFormField.Color)
}
@Test
fun `toEditForm carries a raw colour with no key`() {
val prefilled = detail(eventColor = 0xFF8E24AA.toInt())
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.colorKey).isNull()
assertThat(prefilled.color).isEqualTo(0xFF8E24AA.toInt())
}
@Test
fun `toEditForm leaves an inheriting event without a colour`() {
val prefilled = detail()
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.colorKey).isNull()
assertThat(prefilled.color).isNull()
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
}
} }