From e194da376693386436116810db3b859dce0714e5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 16 Jun 2026 09:49:14 +0200 Subject: [PATCH] =?UTF-8?q?release:=20cut=20v2.2.0=20=E2=80=94=20tap-to-cr?= =?UTF-8?q?eate=20+=20local=20calendar=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .planning/ROADMAP.md | 201 +++++- .planning/STATE.md | 43 +- CHANGELOG.md | 23 + app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 12 + .../data/calendar/CalendarDataSource.kt | 90 +++ .../calendula/data/calendar/CalendarMapper.kt | 34 +- .../data/calendar/CalendarRepository.kt | 9 + .../data/calendar/CalendarRepositoryImpl.kt | 18 + .../calendula/data/calendar/Projections.kt | 7 + .../jeanlucmakiola/calendula/domain/Models.kt | 11 + .../calendula/ui/CalendarHost.kt | 28 +- .../calendula/ui/calendars/CalendarsScreen.kt | 602 ++++++++++++++++++ .../ui/calendars/CalendarsViewModel.kt | 71 +++ .../calendula/ui/common/InlineTextField.kt | 74 +++ .../calendula/ui/day/DayScreen.kt | 36 +- .../calendula/ui/edit/EventEditScreen.kt | 40 +- .../calendula/ui/edit/EventEditViewModel.kt | 27 +- .../calendula/ui/month/MonthScreen.kt | 3 +- .../calendula/ui/settings/SettingsScreen.kt | 29 + .../calendula/ui/week/WeekScreen.kt | 35 +- app/src/main/res/values-de/strings.xml | 21 + app/src/main/res/values/strings.xml | 21 + .../data/calendar/CalendarMapperTest.kt | 33 + .../calendar/CalendarRepositoryImplTest.kt | 59 ++ .../data/calendar/FakeCalendarDataSource.kt | 28 + 26 files changed, 1459 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 40f2e1d..72c0845 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -107,43 +107,185 @@ Deliberately deferred (add only if needed): - Snooze / dismiss notification actions (Etar has them) - Battery-optimization exemption prompt for delivery reliability -## v3.0 — Power-User Features +## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15) -- Home-screen widget -- Full-text search -- Tablet / foldable layouts -- 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) +- Month grid shows real events as continuous multi-day bars (not just dots) +- View section in the navigation drawer to switch Month / Week / Day +- Fix: text cursor no longer jumps in event text fields -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: -- 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 -- Agenda view (fourth view: upcoming events grouped by day; natural widget data source) +--- + +# 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** *(next, high prio)* — see scope below +4. Per-event color — reuses the calendar color picker/palette; closes the create/edit theme +5. Duplicate event — 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 *(next, high prio)* + +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 +- Tablet / foldable layouts *(was v3.0)* +- Full-text search *(was v3.0)* -Reminders, round two: -- Snooze + dismiss actions on the notification (snooze needs an exact-alarm/WorkManager decision) +## Event editing & creation + +- 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 -Event niceties: -- 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) +## Sharing & interop -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 -- Jump to date (un-cut from V1 — drawer date picker) -Consciously rejected: travel time / weather / smart suggestions (network, -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) +## Locations & People *(go/no-go, captured 2026-06-11)* Beyond classic calendar-client scope; discussed, deliberately not planned in detail yet: @@ -163,3 +305,10 @@ in detail yet: - **Attendee editing / invites from contacts** — own milestone; writing `Attendees` rows touches sync-adapter invitation behavior (Google vs 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) diff --git a/.planning/STATE.md b/.planning/STATE.md index 4772408..e756d9b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,13 +1,14 @@ # Calendula — Current State -*Last updated: 2026-06-11* +*Last updated: 2026-06-16* ## Status -**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11. -**Phase:** between milestones. Next: v3.0 (power-user features) and the -go/no-go on the "Locations & People" idea backlog (`ROADMAP.md`). Docs -pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed). +**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11; +v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15. +**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local +calendar management with per-calendar "manage in source app" deep-links) +shipped 2026-06-16. The backlog is now organised by theme in `ROADMAP.md`. ## Progress @@ -71,10 +72,32 @@ pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed). quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy 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` + ## Next -1. Decide the "Locations & People" go/no-go (INTERNET permission question) - — see `ROADMAP.md` idea backlog -2. v3.0 scoping: widget, full-text search, tablet layouts, ICS import, - calendar-move -3. Monitor the F-Droid build/publish for the v1.4.0 / v2.0.0 tags +1. Monitor the F-Droid build/publish for the v2.2.0 tag +2. Decide the "Locations & People" and "remote calendar create/edit" + go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md` +3. **Settings redesign & restructure** is the agreed high-prio next item + (2026-06-16) — group into M3 cards / sub-screens, and migrate the + theme/week-start/language `DropdownMenu` selectors to the OptionCard modal + default (current dropdowns violate `option-card-modal-style-default`). + Structure + style pass only, no new settings features. +4. **Per-event color** follows — reuses the color picker + palette plumbing + from local calendar management; finishes the create/edit theme. +5. Then agenda view (strategic, backs a future widget); jump-to-date and + duplicate event remain cheap follow-ups. Full ranked sequence in + `ROADMAP.md` → "Near-term sequence". diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5a47c..7f4f3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 69815c2..681306b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // (e.g. v2.0.0 -> 20000). These committed values are the dev/local // default; keep them matching the latest released tag. See docs/RELEASING.md. - versionCode = 20100 - versionName = "2.1.0" + versionCode = 20200 + versionName = "2.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index de39da0..e26af49 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,18 @@ + + + + + + + + fun eventDetail(eventId: Long): EventDetail? + /** + * 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`. */ fun insertEvent(form: EventForm): Long @@ -105,6 +119,76 @@ class AndroidCalendarDataSource @Inject constructor( CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", )?.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 { val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { ContentUris.appendId(this, beginMillis) @@ -425,5 +509,11 @@ class AndroidCalendarDataSource @Inject constructor( private companion object { 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" } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt index 1e2c2f0..dda3126 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt @@ -3,14 +3,26 @@ package de.jeanlucmakiola.calendula.data.calendar import android.provider.CalendarContract import de.jeanlucmakiola.calendula.domain.CalendarSource -internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource( - id = getLong(CalendarProjection.IDX_ID), - displayName = getString(CalendarProjection.IDX_DISPLAY_NAME) - ?: Fallbacks.UNNAMED_CALENDAR, - accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(), - accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(), - color = getInt(CalendarProjection.IDX_COLOR), - isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0, - canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >= - CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR, -) +internal fun ColumnReader.toCalendarSource(): CalendarSource { + val accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty() + val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL + return CalendarSource( + id = getLong(CalendarProjection.IDX_ID), + displayName = getString(CalendarProjection.IDX_DISPLAY_NAME) + ?: Fallbacks.UNNAMED_CALENDAR, + accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(), + accountType = accountType, + 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 + }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 14bd5e5..59cde96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -12,6 +12,15 @@ interface CalendarRepository { fun instances(range: ClosedRange): Flow> suspend fun eventDetail(eventId: Long): EventDetail + /** 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`. */ suspend fun createEvent(form: EventForm): Long diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index c880d74..5e2b50b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -70,6 +70,24 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) } + 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) { dataSource.insertEvent(form) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 5d66215..6bb8318 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -11,8 +11,14 @@ internal object CalendarProjection { CalendarContract.Calendars.CALENDAR_COLOR, CalendarContract.Calendars.VISIBLE, 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_DISPLAY_NAME = 1 const val IDX_ACCOUNT_NAME = 2 @@ -20,6 +26,7 @@ internal object CalendarProjection { const val IDX_COLOR = 4 const val IDX_VISIBLE = 5 const val IDX_ACCESS_LEVEL = 6 + const val IDX_DESCRIPTION = 7 } internal object InstanceProjection { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 21d0581..1e08f58 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -15,6 +15,17 @@ data class CalendarSource( * subscriptions, birthday calendars and other read-only sources. */ 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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index c511cdb..d1cb75d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.day.DayScreen @@ -86,13 +87,23 @@ fun CalendarHost( var showSettings by rememberSaveable { mutableStateOf(false) } 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: // [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(null) } var heldCreateIso by remember { mutableStateOf(null) } - val onCreateEvent: (LocalDate) -> Unit = { date -> + var createStartMinutes by rememberSaveable { mutableStateOf(null) } + var heldCreateMinutes by remember { mutableStateOf(null) } + val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> heldCreateIso = date.toString() createDateIso = date.toString() + heldCreateMinutes = startMinutes + createStartMinutes = startMinutes } // Edit form (v1.3) — reuses the detail screen's occurrence key; for @@ -162,6 +173,7 @@ fun CalendarHost( (createDateIso ?: heldCreateIso)?.let { iso -> EventEditScreen( initialDateIso = iso, + initialStartMinutes = createStartMinutes ?: heldCreateMinutes, onClose = { createDateIso = null }, onSaved = { createDateIso = null }, ) @@ -193,7 +205,19 @@ fun CalendarHost( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), 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 }) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt new file mode 100644 index 0000000..0e2b586 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -0,0 +1,602 @@ +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.border +import androidx.compose.foundation.clickable +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.Check +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.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +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.text.style.TextOverflow +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.InlineTextField +import de.jeanlucmakiola.calendula.ui.common.pastelize + +/** 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(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 }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CalendarsList( + local: List, + synced: List, + error: Boolean, + onConsumeError: () -> Unit, + onBack: () -> Unit, + onAdd: () -> Unit, + onEdit: (CalendarSource) -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + val writeErrorText = stringResource(R.string.calendars_write_error) + val dark = isSystemInDarkTheme() + + BackHandler(onBack = onBack) + LaunchedEffect(error) { + if (error) { + snackbarHostState.showSnackbar(writeErrorText) + onConsumeError() + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.calendars_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back), + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + SectionHeader(stringResource(R.string.calendars_local_header)) + if (local.isEmpty()) { + HintText(stringResource(R.string.calendars_local_empty)) + } else { + local.forEach { calendar -> + CalendarRow( + name = calendar.displayName, + color = calendar.color, + dark = dark, + subtitle = calendar.description, + onClick = { onEdit(calendar) }, + trailing = { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.calendars_edit_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + ) + } + } + FilledTonalButton( + onClick = onAdd, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.calendars_add)) + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SectionHeader(stringResource(R.string.calendars_synced_header)) + HintText(stringResource(R.string.calendars_synced_hint)) + synced + .groupBy { it.accountName.ifBlank { it.accountType } } + .forEach { (account, cals) -> + SyncedAccountGroup( + account = account, + accountType = cals.first().accountType, + calendars = cals, + dark = dark, + ) + } + AddAccountButton() + Spacer(Modifier.height(24.dp)) + } + } +} + +@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)) + ColorPalette(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() } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ColorPalette(selected: Int, onSelect: (Int) -> Unit, dark: Boolean) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CALENDAR_COLOR_PALETTE.forEach { argb -> + val isSelected = argb == selected + // Show the pastel the calendar will actually render as, not the raw hue. + 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), + ) + } + } + } + } +} + +@Composable +private fun SyncedAccountGroup( + account: String, + accountType: String, + calendars: List, + dark: Boolean, +) { + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 16.dp, top = 12.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)) + } + } + calendars.forEach { calendar -> + CalendarRow(name = calendar.displayName, color = calendar.color, dark = dark) + } +} + +@Composable +private fun AddAccountButton() { + val context = LocalContext.current + FilledTonalButton( + onClick = { + runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) } + }, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.calendars_add_account)) + } +} + +@Composable +private fun CalendarRow( + name: String, + color: Int, + dark: Boolean, + subtitle: String? = null, + onClick: (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(pastelize(color, dark)), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = name, style = MaterialTheme.typography.bodyLarge) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(8.dp)) + trailing() + } + } +} + +@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 +} + +/** Google-Calendar-style palette; values are ARGB ints for `Calendars.CALENDAR_COLOR`. */ +internal val CALENDAR_COLOR_PALETTE: List = listOf( + 0xFFD50000, // red + 0xFFE67C00, // orange + 0xFFF6BF26, // amber + 0xFF33B679, // green + 0xFF0B8043, // dark green + 0xFF039BE5, // blue + 0xFF3F51B5, // indigo + 0xFF8E24AA, // purple + 0xFF616161, // graphite +).map { it.toInt() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt new file mode 100644 index 0000000..ed50e04 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -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> = + 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 = _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 + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt new file mode 100644 index 0000000..85105e0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 04f67b0..600b0ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -104,7 +105,7 @@ fun DayScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, - onCreateEvent: (LocalDate) -> Unit, + onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), @@ -185,7 +186,7 @@ fun DayScreen( todayVisible = !isOnToday, todayText = stringResource(R.string.day_today_action), onToday = jumpToToday, - onCreate = { onCreateEvent(date) }, + onCreate = { onCreateEvent(date, null) }, ) }, ) { innerPadding -> @@ -197,6 +198,7 @@ fun DayScreen( onSwipePrev = goPrev, onRetry = jumpToToday, onEventClick = onEventClick, + onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, modifier = Modifier .padding(innerPadding) .fillMaxSize(), @@ -214,6 +216,7 @@ private fun DayContent( onSwipePrev: () -> Unit, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { val density = LocalDensity.current @@ -282,6 +285,7 @@ private fun DayContent( scrollState = scrollState, allDayHeight = allDayHeight, onEventClick = onEventClick, + onCreateAt = onCreateAt, ) } } @@ -294,6 +298,7 @@ private fun DaySuccess( scrollState: ScrollState, allDayHeight: Dp, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { // 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 // scrolling timeline below. 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, scrollState: ScrollState, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, ) { val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() @@ -474,7 +485,9 @@ private fun Timeline( DayColumnCard( blocks = state.timed, dark = dark, + date = state.date, onEventClick = onEventClick, + onCreateAt = onCreateAt, modifier = Modifier .fillMaxWidth() .height(totalHeight), @@ -488,9 +501,12 @@ private fun Timeline( private fun DayColumnCard( blocks: List, dark: Boolean, + date: LocalDate, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { + val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } Card( // Plain rectangular column — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -500,7 +516,19 @@ private fun DayColumnCard( ), 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 blocks.forEach { block -> val laneWidth = colWidth / block.laneCount diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 9a09f32..7d28404 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -110,6 +110,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.toRRule +import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -147,6 +148,7 @@ fun EventEditScreen( onClose: () -> Unit, onSaved: () -> Unit, editKey: LongArray? = null, + initialStartMinutes: Int? = null, viewModel: EventEditViewModel = hiltViewModel(), ) { LaunchedEffect(initialDateIso, editKey) { @@ -159,7 +161,7 @@ fun EventEditScreen( } else { val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - viewModel.openNew(date) + viewModel.openNew(date, initialStartMinutes) } } val state by viewModel.state.collectAsStateWithLifecycle() @@ -1436,8 +1438,9 @@ private fun EditCard( } /** - * Borderless text input used inside the cards (and as the headline title) — - * no underline, no outline, just the card's tonal surface behind it. + * Borderless text input used inside the cards (and as the headline title). + * Thin wrapper over the shared [InlineTextField] so the form and the rest of + * the app share one input style. */ @Composable private fun InlineField( @@ -1452,36 +1455,15 @@ private fun InlineField( .fillMaxWidth() .padding(vertical = 4.dp), ) { - val resolvedStyle = textStyle.copy( - color = if (textStyle.color.isSpecified) { - textStyle.color - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - BasicTextField( + InlineTextField( value = value, onValueChange = onValueChange, - textStyle = resolvedStyle, + placeholder = placeholder, + modifier = modifier, + textStyle = textStyle, singleLine = singleLine, minLines = minLines, - keyboardOptions = KeyboardOptions(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, + keyboardType = keyboardType, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 22332a1..15f0d38 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -138,21 +138,26 @@ class EventEditViewModel @Inject constructor( ) /** - * Initialise a fresh form for a new event on [date]. No-op when a form is - * already open, so user input survives configuration changes; [reset] - * clears it when the screen closes. + * Initialise a fresh form for a new event on [date]. [startMinutes] (minutes + * from midnight) anchors the start when the form is opened by tapping a slot + * 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 val zone = TimeZone.currentSystemDefault() val now = Clock.System.now() - val start = if (date == now.toLocalDateTime(zone).date) { - // Today: the next full hour (may roll into tomorrow before midnight). - val hourMillis = 3_600_000L - val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis - Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) - } else { - LocalDateTime(date, LocalTime(9, 0)) + val start = when { + startMinutes != null -> + LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)) + date == now.toLocalDateTime(zone).date -> { + // Today: the next full hour (may roll into tomorrow before midnight). + val hourMillis = 3_600_000L + val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis + Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) + } + else -> LocalDateTime(date, LocalTime(9, 0)) } val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone) _form.value = EventForm(calendarId = null, start = start, end = end) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ec10559..beb43c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -86,7 +86,7 @@ fun MonthScreen( onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onOpenSettings: () -> Unit, - onCreateEvent: (LocalDate) -> Unit, + onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { @@ -166,6 +166,7 @@ fun MonthScreen( onCreateEvent( if (isOnCurrentMonth) today else LocalDate(month.year, month.month, 1), + null, ) }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index a7c6e84..2c29c4d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -63,6 +63,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField @Composable fun SettingsScreen( onBack: () -> Unit, + onManageCalendars: () -> Unit, modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel(), ) { @@ -141,6 +142,14 @@ fun SettingsScreen( onCheckedChange = viewModel::setRemindersEnabled, ) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + SectionHeader(stringResource(R.string.settings_section_calendars)) + NavigationRow( + title = stringResource(R.string.settings_manage_calendars), + subtitle = stringResource(R.string.settings_manage_calendars_hint), + onClick = onManageCalendars, + ) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) SectionHeader(stringResource(R.string.settings_section_language)) LanguageRow() @@ -377,6 +386,26 @@ private fun AboutRow(title: String, value: String) { } } +@Composable +private fun NavigationRow(title: String, subtitle: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun FormFieldRow( title: String, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 32f95c2..65b2b9f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -112,7 +113,7 @@ fun WeekScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, - onCreateEvent: (LocalDate) -> Unit, + onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), ) { @@ -194,7 +195,7 @@ fun WeekScreen( // Anchor on today when it's in view, else the week's first day. val today = Clock.System.now() .toLocalDateTime(TimeZone.currentSystemDefault()).date - onCreateEvent(if (isOnCurrentWeek) today else weekStart) + onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) }, ) }, @@ -207,6 +208,7 @@ fun WeekScreen( onSwipePrev = goPrev, onRetry = jumpToToday, onEventClick = onEventClick, + onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, modifier = Modifier .padding(innerPadding) .fillMaxSize(), @@ -224,6 +226,7 @@ private fun WeekContent( onSwipePrev: () -> Unit, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { val density = LocalDensity.current @@ -295,6 +298,7 @@ private fun WeekContent( scrollState = scrollState, allDayHeight = allDayHeight, onEventClick = onEventClick, + onCreateAt = onCreateAt, ) } } @@ -307,6 +311,7 @@ private fun WeekSuccess( scrollState: ScrollState, allDayHeight: Dp, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { Column( @@ -320,7 +325,12 @@ private fun WeekSuccess( // Breathing room between the (colour-shifting) top section and the // scrolling timeline below. 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, scrollState: ScrollState, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, ) { val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() @@ -588,7 +599,9 @@ private fun Timeline( DayColumnCard( blocks = state.timedByDay[day].orEmpty(), dark = dark, + date = day, onEventClick = onEventClick, + onCreateAt = onCreateAt, modifier = Modifier .weight(1f) .fillMaxHeight(), @@ -604,9 +617,12 @@ private fun Timeline( private fun DayColumnCard( blocks: List, dark: Boolean, + date: LocalDate, onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { + val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } Card( // Plain rectangular columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -616,7 +632,18 @@ private fun DayColumnCard( ), 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 blocks.forEach { block -> val laneWidth = colWidth / block.laneCount diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 01d8422..c21fa89 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -211,6 +211,9 @@ Benachrichtigungen Termin-Erinnerungen Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab. + Kalender + Kalender verwalten + Lokale Kalender anlegen, synchronisierte verwalten Sprache App-Sprache Systemstandard @@ -222,4 +225,22 @@ MIT Quellcode Öffnen + + + Kalender + Deine Kalender + Noch keine lokalen Kalender. Lege einen an, um Termine nur auf diesem Gerät zu speichern. + Kalender hinzufügen + Synchronisierte Kalender + Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App. + Verwalten + Konto hinzufügen + Neuer Kalender + Kalender bearbeiten + Name + Farbe + Beschreibung hinzufügen + Kalender löschen? + \"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt. + Änderung konnte nicht gespeichert werden. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44aff51..8f67934 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -212,6 +212,9 @@ Notifications Event reminders Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two. + Calendars + Manage calendars + Create local calendars; manage synced ones Language App language System default @@ -223,5 +226,23 @@ MIT Source code Open + + + Calendars + Your calendars + No local calendars yet. Create one to keep events on this device only. + Add calendar + Synced calendars + These come from accounts on your device. Create and edit them in their own app. + Manage + Add account + New calendar + Edit calendar + Name + Color + Add a description + Delete calendar? + \"%1$s\" and all of its events will be permanently removed from this device. + Couldn\'t save the change. https://gitea.jeanlucmakiola.de/makiolaj/calendula diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt index d5e0ec3..ea02483 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt @@ -14,6 +14,7 @@ class CalendarMapperTest { color: Int = 0, visible: Int = 1, accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER, + description: String? = null, ): MapColumnReader = MapColumnReader( CalendarProjection.IDX_ID to id, CalendarProjection.IDX_DISPLAY_NAME to displayName, @@ -22,6 +23,7 @@ class CalendarMapperTest { CalendarProjection.IDX_COLOR to color, CalendarProjection.IDX_VISIBLE to visible, CalendarProjection.IDX_ACCESS_LEVEL to accessLevel, + CalendarProjection.IDX_DESCRIPTION to description, ) @Test @@ -90,4 +92,35 @@ class CalendarMapperTest { val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE) 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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 22aaa70..e7708ca 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -327,6 +327,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 fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest { val fake = FakeCalendarDataSource().apply { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index b465353..05a0fee 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -26,6 +26,18 @@ internal class FakeCalendarDataSource : CalendarDataSource { val deletedEventIds = mutableListOf() val deletedOccurrences = mutableListOf>() val deletedFromOccurrences = mutableListOf>() + /** 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() + val updatedCalendars = mutableListOf() + val deletedCalendarIds = mutableListOf() private val listeners = mutableListOf<() -> Unit>() @@ -34,6 +46,22 @@ internal class FakeCalendarDataSource : CalendarDataSource { instancesResult(beginMillis, endMillis) override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) + 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 { writeError?.let { throw it } insertedForms += form