release: cut v2.2.0 — tap-to-create + local calendar management
All checks were successful
CI / ci (push) Successful in 8m53s
Release — F-Droid repo + Gitea release / ci (push) Successful in 1m59s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m57s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s

Day/week: tap an empty slot to open the create form prefilled with that
day and the tapped hour (snapped to the hour, 1 h long). Threaded a start
time through CalendarHost → EventEditScreen → openNew; the FAB keeps its
default.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 09:49:14 +02:00
parent 15fb76005c
commit e194da3766
26 changed files with 1459 additions and 100 deletions

View File

@@ -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)

View File

@@ -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".

View File

@@ -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

View File

@@ -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"
}

View File

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

View File

@@ -6,6 +6,7 @@ import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
@@ -36,6 +37,19 @@ interface CalendarDataSource {
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
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<EventInstance> {
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"
}
}

View File

@@ -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(
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 = getString(CalendarProjection.IDX_ACCOUNT_TYPE).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
},
)
}

View File

@@ -12,6 +12,15 @@ interface CalendarRepository {
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) }
val onCreateEvent: (LocalDate) -> Unit = { date ->
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
heldCreateIso = date.toString()
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 })
}
}
}

View File

@@ -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<Long?>(null) }
var editorSession by rememberSaveable { mutableStateOf(0) }
if (editorId != null) {
val editing = calendars.firstOrNull { it.id == editorId }
CalendarEditor(
sessionKey = editorSession,
isNew = editorId == NEW_CALENDAR_ID,
initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
initialDescription = editing?.description.orEmpty(),
onSave = { name, color, description ->
val id = editorId
if (id == null || id == NEW_CALENDAR_ID) {
viewModel.createCalendar(name, color, description)
} else {
viewModel.updateCalendar(id, name, color, description)
}
editorId = null
},
onDelete = {
editorId?.takeIf { it != NEW_CALENDAR_ID }?.let(viewModel::deleteCalendar)
editorId = null
},
onClose = { editorId = null },
)
} else {
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
error = error,
onConsumeError = viewModel::consumeError,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
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<CalendarSource>,
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<Int> = listOf(
0xFFD50000, // red
0xFFE67C00, // orange
0xFFF6BF26, // amber
0xFF33B679, // green
0xFF0B8043, // dark green
0xFF039BE5, // blue
0xFF3F51B5, // indigo
0xFF8E24AA, // purple
0xFF616161, // graphite
).map { it.toInt() }

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.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<TimedBlock>,
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

View File

@@ -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,
)
}

View File

@@ -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) {
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))
}
else -> LocalDateTime(date, LocalTime(9, 0))
}
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)

View File

@@ -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,
)
},
)

View File

@@ -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,

View File

@@ -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<TimedBlock>,
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

View File

@@ -211,6 +211,9 @@
<string name="settings_section_notifications">Benachrichtigungen</string>
<string name="settings_reminders">Termin-Erinnerungen</string>
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
<string name="settings_section_calendars">Kalender</string>
<string name="settings_manage_calendars">Kalender verwalten</string>
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
<string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
@@ -222,4 +225,22 @@
<string name="settings_license_value">MIT</string>
<string name="settings_source">Quellcode</string>
<string name="settings_source_open">Öffnen</string>
<!-- Calendar manager -->
<string name="calendars_title">Kalender</string>
<string name="calendars_local_header">Deine Kalender</string>
<string name="calendars_local_empty">Noch keine lokalen Kalender. Lege einen an, um Termine nur auf diesem Gerät zu speichern.</string>
<string name="calendars_add">Kalender hinzufügen</string>
<string name="calendars_synced_header">Synchronisierte Kalender</string>
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
<string name="calendars_manage_in_app">Verwalten</string>
<string name="calendars_add_account">Konto hinzufügen</string>
<string name="calendars_new_title">Neuer Kalender</string>
<string name="calendars_edit_title">Kalender bearbeiten</string>
<string name="calendars_name_label">Name</string>
<string name="calendars_color_label">Farbe</string>
<string name="calendars_description_hint">Beschreibung hinzufügen</string>
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt.</string>
<string name="calendars_write_error">Änderung konnte nicht gespeichert werden.</string>
</resources>

View File

@@ -212,6 +212,9 @@
<string name="settings_section_notifications">Notifications</string>
<string name="settings_reminders">Event reminders</string>
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
<string name="settings_section_calendars">Calendars</string>
<string name="settings_manage_calendars">Manage calendars</string>
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
<string name="settings_section_language">Language</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
@@ -223,5 +226,23 @@
<string name="settings_license_value">MIT</string>
<string name="settings_source">Source code</string>
<string name="settings_source_open">Open</string>
<!-- Calendar manager -->
<string name="calendars_title">Calendars</string>
<string name="calendars_local_header">Your calendars</string>
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
<string name="calendars_add">Add calendar</string>
<string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage</string>
<string name="calendars_add_account">Add account</string>
<string name="calendars_new_title">New calendar</string>
<string name="calendars_edit_title">Edit calendar</string>
<string name="calendars_name_label">Name</string>
<string name="calendars_color_label">Color</string>
<string name="calendars_description_hint">Add a description</string>
<string name="calendars_delete_confirm_title">Delete calendar?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
<string name="calendars_write_error">Couldn\'t save the change.</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
</resources>

View File

@@ -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()
}
}

View File

@@ -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 {

View File

@@ -26,6 +26,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
/** Id returned by the next [createLocalCalendar]. */
var nextCalendarId: Long = 500L
data class CreatedCalendar(val displayName: String, val color: Int, val description: String?)
data class UpdatedCalendar(
val id: Long,
val displayName: String,
val color: Int,
val description: String?,
)
val createdCalendars = mutableListOf<CreatedCalendar>()
val updatedCalendars = mutableListOf<UpdatedCalendar>()
val deletedCalendarIds = mutableListOf<Long>()
private val listeners = mutableListOf<() -> Unit>()
@@ -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