Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e194da3766 |
@@ -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)
|
||||
|
||||
@@ -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".
|
||||
|
||||
23
CHANGELOG.md
23
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user