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