6 Commits

Author SHA1 Message Date
d028b70e6e release: cut v2.0.0 — write support complete
Some checks failed
CI / ci (push) Failing after 1m7s
Build and Release to F-Droid / ci (push) Successful in 5m47s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m58s
Version bumped to 2.0.0 / 13. No code changes beyond the version — 2.0.0
closes out Milestone 2 (write support, v1.1 through v2.0): the final slice
is the save-conflict dialog (external change → overwrite/discard, external
delete → informational close), plus the store refresh: descriptions and
README describe write support and reminders, and fastlane screenshots
(DE+EN, six each) ship for F-Droid. CHANGELOG [2.0.0] carries the details.

Quick-add was cut from scope (the prefilled form covers it); calendar
switching while editing moved to the v3 backlog. Both documented in the
roadmap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:15:50 +02:00
626623bb6e feat(edit): conflict dialog on save + store metadata refresh (v2.0)
No locking (plan 03, decision 5): openForEdit keeps an EditSnapshot — the
prefilled form plus the raw Events-row times, which the form itself can't
see (it derives its times from the tapped occurrence, so an externally
moved event would otherwise stay invisible). Right before writing,
performSave re-reads the event and compares snapshots: a mismatch parks
the save in SaveUiState.AwaitingConflict carrying the already-chosen
recurring scope, and the dialog offers overwrite / discard / cancel
(OptionCard style). Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. A deleted event lands in
SaveUiState.Gone — an informational dialog that closes form and detail.
Fields the form can't write (attendees, status, self response, reminder
methods) are excluded from the comparison so sync noise can't fake a
conflict. The load-time zone is pinned in the EditTarget so a device
timezone change mid-edit can't either.

Store metadata: F-Droid descriptions (DE+EN) and the README stop claiming
read-only and now describe write support and reminder delivery. New
fastlane phoneScreenshots (6 per locale: week/month/day/detail/form/
reminder onboarding), captured on-device against demo-only calendars.

Tests: EditSnapshot equality (unchanged event, field change, row-time move
the form can't see, non-writable changes stay quiet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:14:27 +02:00
264b2a86c1 release: cut v1.4.0 — reminder notifications
All checks were successful
CI / ci (push) Successful in 8m7s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m40s
Build and Release to F-Droid / ci (push) Successful in 2m3s
Version bumped to 1.4.0 / 12. No code changes beyond the version — 1.4.0 is
the reviewed-and-approved reminder slice: the EVENT_REMINDER receiver posting
due CalendarAlerts on a dedicated channel, tap-to-detail, the one-time
onboarding step requesting POST_NOTIFICATIONS with the duplicate-reminders
warning, and the Settings mirror. CHANGELOG [1.4.0] carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:24:10 +02:00
b03bd67678 feat(reminders): reminder notifications — EVENT_REMINDER receiver, onboarding step, settings toggle (v1.4)
Calendula now posts event reminders itself (the Etar model): the provider
schedules the alarms and broadcasts EVENT_REMINDER, but a calendar app must
turn them into visible notifications — essential for users whose only
calendar app this is. A manifest-registered, exported receiver (data scheme
content://com.android.calendar) wakes us at reminder time; no foreground
service, no own alarm scheduling.

Delivery path (data/reminders/): EventReminderReceiver (Hilt, goAsync) →
ReminderAlertStore queries CalendarAlerts for STATE_SCHEDULED rows with
ALARM_TIME <= now → ReminderNotifier posts one notification per alert on a
dedicated high-importance channel, then best-effort marks rows FIRED
(needs WRITE_CALENDAR; without it a re-broadcast silently replaces — tag
per alert + setOnlyAlertOnce). Swiped notifications never return: FIRED
rows are never re-queried, so no dismiss-intent machinery. Research
(AOSP CalendarAlarmManager): the provider creates alert rows only for
METHOD_ALERT reminders, so the email-reminder filter happens upstream.

Tapping opens the event's detail screen: MainActivity is singleTop now,
parses eventId/begin/end extras (onCreate + onNewIntent) into Compose
state, and CalendarHost consumes the key exactly like an event tap.

Onboarding gained a one-time second step after the calendar grant (shared
OnboardingScaffold extracted from PermissionScreen): explains delivery,
warns that a second calendar app with notifications on duplicates
reminders, requests POST_NOTIFICATIONS (dialog on API 33+ only; minSdk 29).
"Not now" turns the feature off; reminders default ON. Settings mirrors
the toggle in a new Notifications section with the duplicate hint, and
re-requests the permission when enabling. Strings DE+EN.

Deliberately deferred (roadmap): snooze/dismiss actions, BOOT_COMPLETED /
exact-alarm scheduling, battery-exemption prompts.

Tests: reminderTimeText (all-day UTC-midnight reading, exclusive end day,
midnight-crossing ranges), reminders/onboarding pref round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:23:34 +02:00
301f105fbc release: cut v1.3.0 — event edit
All checks were successful
CI / ci (push) Successful in 8m4s
Build and Release to F-Droid / ci (push) Successful in 2m0s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m42s
Version bumped to 1.3.0 / 11. No code changes beyond the version — 1.3.0 is
the reviewed-and-approved edit slice: shared form for editing, scope-at-save
for recurring events (this / this and following / all, exception rows and
series splits), three-way recurring delete, simple recurrence picker with
weekly weekday toggles, and the stale-instances split fix. CHANGELOG [1.3.0]
carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:57:44 +02:00
f0e2e12939 feat(edit): event editing — shared form, scoped recurring writes, recurrence picker (v1.3)
The create form (v1.2) now edits: a pencil on the detail screen (writable
calendars only, contextual WRITE upgrade like delete) opens it prefilled via
EventDetail.toEditForm; populated sections always show, the calendar is
fixed, and a dirty-check writes only changed columns (pristine saves are
no-ops). Saving a dirty recurring event parks in SaveUiState.AwaitingScope
and asks how far the change reaches (Google model): "only this event" =
modified-occurrence exception via CONTENT_EXCEPTION_URI (empty optionals as
explicit NULLs since the provider clones the parent row), "this and all
following" = series split (insert new event first, then truncate), "all
events" = series-row update with the time delta applied to the series
DTSTART. A changed rule drops the exception option. Delete gained the same
middle scope.

Recurrence: EventForm.rrule + SimpleRecurrence (FREQ/INTERVAL/UNTIL/COUNT +
weekly BYDAY with locale-ordered weekday toggles) behind a picker on create
and edit; unrepresentable rules render humanized (shared ui/common
RecurrenceText) and survive verbatim. UNTIL validation flags rules ending
before the event starts.

Provider lessons baked in (verified on-device via adb probes): instance
caches regenerate only from an update's own values, so truncation sends the
full time-column set (truncateSeries) — RRULE-only updates left a stale
duplicate occurrence on the split day; UNTIL is written as the local end of
day in UTC (toRRule(zone), previousLocalDayEndUtcMillis) so UTC+x zones
can't leak an extra day. Reminder edits reconcile against actual provider
rows, keeping untouched rows' methods.

Tests: RecurrenceTest (parse/render/round-trip, truncation), update/exception
mapper paths, repository pass-throughs, prefill + populatedFields, raw-title
mapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:57:32 +02:00
64 changed files with 3804 additions and 422 deletions

View File

@@ -30,7 +30,10 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
- Home-screen widget
- Full-text search
- Quick-add
- Custom notifications/reminders (system already handles these)
- ~~Custom notifications/reminders (system already handles these)~~ —
**reversed:** Calendula targets sole-calendar-app users, so no other app
posts reminder notifications. We post them ourselves (Etar model). Planned
for v1.4 — see `ROADMAP.md`.
- Tablet/foldable-specific layouts
- iOS support (Android-only by design)

View File

@@ -53,7 +53,7 @@ after v0.6 (full event read) plus the onboarding-screen polish pass.
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support (in progress)
## v2.0 — Write Support (complete, shipped 2026-06-11)
Delivered in four releasable slices (plan:
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
@@ -64,8 +64,48 @@ guide here, not a contract — scope per slice is decided as we go.
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
| v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned |
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
v2.0 scope was re-cut on 2026-06-11, after v1.4:
- **Occurrence edit** already shipped early, in v1.3.
- **Quick-add** is **cut from scope**: the full form already opens prefilled
(visible day, last-used calendar, optional fields hidden), so the sheet
would only save one screen transition while adding a second create-surface
to maintain. Revisit only if real-world feedback says creation feels heavy.
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
honest implementation is copy+delete like Google Calendar, with sync-identity
and attendee side effects).
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
the row as it was when the form loaded; on external change, ask
overwrite / discard. Closes the silent-clobber gap on synced calendars.
## v1.4 — Reminder Notifications
**Essential**, not nice-to-have: Calendula targets users for whom it is their
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
Calendar. The calendar provider schedules reminders and broadcasts
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
notification — a calendar app must. We become that app (the Etar model).
Scope:
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
no foreground service.
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
notification channel; tap opens event detail.
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
toggle, **default ON**, with copy warning that a second calendar app with
notifications on will cause duplicate reminders. Mirrored into Settings
(reversible).
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
@@ -73,5 +113,7 @@ guide here, not a contract — scope per slice is decided as we go.
- 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)
Order is indicative — community feedback after V1 may re-prioritize.

View File

@@ -5,11 +5,12 @@
## Status
**Milestone:** v2.0 — Write support (milestone 2, in progress)
**Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after
Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day).
Milestone 2 runs in four slices
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3
(edit event). Note: UI slices now hold release until his explicit approval.
**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device
review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip,
stale-instances split bugfix). Milestone 2 runs in four slices
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add,
conflict dialog, polish) is the remaining slice, v1.4 (reminder
notifications) comes first.
## Progress
@@ -44,8 +45,27 @@ Milestone 2 runs in four slices
with provider-correct all-day normalisation (UTC midnights, exclusive end),
domain/mapper/repository tests
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
upgrade), dirty-checked partial `update` on the Events row (recurring:
series DTSTART moves by the user's delta, DURATION instead of DTEND),
reminder diff by minutes (kept rows keep their method), simple recurrence
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
shown humanized), `EventFormField.Recurrence` incl. settings default,
recurrence also available on create; domain/mapper/repository tests.
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
on Mon+Fri"). Review rounds 24: occurrence edit pulled forward from v2.0
and made three-way like delete ("this" = exception row via
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
series update); delete equally three-way (truncation via RRULE UNTIL);
the edit-scope question moved to save time (Google model) — dirty
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
the "only this event" option
## Next
1. v1.3edit event: reuse the form, series edit, reminder edit, simple
recurrence picker
2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0
1. v1.4reminder notifications (essential for sole-app use): `EVENT_REMINDER`
receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with
default-on toggle + duplicate-reminder warning (Etar model)
2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release
3. Monitor the F-Droid build/publish for v1.1.0 v1.3.0

View File

@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.0.0] — 2026-06-11
### Added
- Conflict handling when saving an edit: if the event changed elsewhere
(sync, another device) while the form was open, saving now asks whether
to keep or discard your changes instead of silently overwriting the
edited fields — and tells you when the event was deleted in the meantime.
"Keep" still writes only the fields you touched; external changes to
untouched fields survive either way
- F-Droid store screenshots (German + English), captured with demo data
### Changed
- F-Droid description and README no longer claim the app is read-only —
they now describe write support and reminder delivery
### Fixed
- `versionName`/`versionCode` bumped to 2.0.0 / 13 — closing out the
write-support milestone (v1.1 through v2.0)
## [1.4.0] — 2026-06-11
### Added
- Reminder notifications (v1.4): Calendula now delivers event reminders as
notifications itself — the system schedules them but posts nothing, so a
calendar app must (essential when Calendula is the only one installed).
Due reminders appear on a dedicated "Event reminders" channel; tapping one
opens the event's detail screen. Email reminders are never posted (the
provider only schedules alert-type reminders)
- A one-time onboarding step after the calendar grant introduces reminders,
requests the notification permission (Android 13+), and warns that a second
calendar app with notifications on will duplicate them. "Not now" leaves
the feature off
- Settings gained a "Notifications" section mirroring the choice: an event-
reminders toggle (default on) with the duplicate-reminders hint; turning it
on re-requests the notification permission when missing
### Fixed
- `versionName`/`versionCode` bumped to 1.4.0 / 12
## [1.3.0] — 2026-06-11
### Added
- Event editing: a pencil action on the detail screen (writable calendars
only) opens the event form prefilled with the event. Only fields you
actually changed are written back; saving an untouched form is a no-op.
Sections holding data are always shown, regardless of the form-field
defaults; the calendar itself can't be changed while editing
- Recurring events — scoped writes, chosen when saving (Google model):
"only this event" (a modified-occurrence exception), "this and all
following" (the series is split at the occurrence), or "all events in
the series". Changing the recurrence rule rules out "only this event"
- Deleting a recurring event gained the middle option too: "this and all
following events" ends the series just before the chosen occurrence
- Recurrence picker (create and edit): one-tap daily/weekly/monthly/yearly
presets plus a custom step with interval + unit, weekday toggles for
weekly rules ("every week on Mon and Fri"), and an end condition (never /
on a date / after a number of times). Rules the picker can't express
(e.g. "second Thursday monthly") are shown humanized and preserved
verbatim unless replaced. Recurrence also joined the optional form
fields and their settings defaults
- Validation: a repeat that would end before the event starts is flagged
(it would otherwise vanish from every view)
### Changed
- Editing reminders reconciles against the provider's actual rows:
reminders you didn't touch keep their method (e.g. email reminders on
synced events survive unrelated edits)
- The contextual WRITE_CALENDAR upgrade for v1.0 installs covers the edit
action like delete
### Fixed
- Splitting a series ("this and following") sends the complete time-column
set in one update, so the provider regenerates its cached instances — an
RRULE-only update left a stale duplicate of the tapped occurrence on the
split day
- RRULE UNTIL values are written as the local end of day expressed in UTC
(instead of a flat `T235959Z`), so recurrences can't leak an extra day in
timezones ahead of UTC
- `versionName`/`versionCode` bumped to 1.3.0 / 11
## [1.2.1] — 2026-06-11
### Added

View File

@@ -8,10 +8,14 @@ word "calendar". Calendula reads from Android's built-in `CalendarContract`,
so any calendar source synced to your device (CalDAV via DAVx5, Google,
local, WebCal subscriptions, ...) is shown.
## Features (V1)
## Features
- Month, Week, and Day views
- Read-only event details (write support comes in V2)
- Full event details — attendees, reminders, recurrence, availability, and more
- Create, edit, and delete events — recurring events with scoped writes
(only this event / this and all following / whole series) and a simple
recurrence picker
- Reminder notifications, delivered by Calendula itself (tap opens the event)
- Multi-calendar visibility toggle
- Material You Dynamic Color (Android 12+)
- Light/Dark theme follows system

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
versionCode = 10
versionName = "1.2.1"
versionCode = 13
versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".CalendulaApp"
@@ -19,6 +20,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -26,6 +28,20 @@
</intent-filter>
</activity>
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
no notification itself — a calendar app must (v1.4, Etar model).
Exported: the broadcast arrives from the provider's process. -->
<receiver
android:name=".data.reminders.EventReminderReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.EVENT_REMINDER" />
<data
android:host="com.android.calendar"
android:scheme="content" />
</intent-filter>
</receiver>
<!-- Persists the per-app language (M4) on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service

View File

@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -7,7 +9,10 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
@@ -18,9 +23,16 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a
// tap into the running activity; CalendarHost consumes and clears it.
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -35,8 +47,51 @@ class MainActivity : ComponentActivity() {
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
RootScreen(modifier = Modifier.fillMaxSize())
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
}
private fun Intent.detailKeyOrNull(): LongArray? {
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
eventId,
getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
getLongExtra(EXTRA_END_MILLIS, 0L),
)
}
companion object {
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
/**
* Intent opening the detail screen of one occurrence (reminder
* notifications). The synthetic data URI keys the intent so
* PendingIntents for different occurrences never collapse into one.
*/
fun eventDetailIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
): Intent = Intent(context, MainActivity::class.java).apply {
data = "calendula://event/$eventId/$beginMillis".toUri()
putExtra(EXTRA_EVENT_ID, eventId)
putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
putExtra(EXTRA_END_MILLIS, endMillis)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}

View File

@@ -17,7 +17,9 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import java.time.ZoneId
import java.time.ZoneOffset
import javax.inject.Inject
import javax.inject.Singleton
@@ -37,6 +39,44 @@ interface CalendarDataSource {
/** Insert a new event; returns the new `Events._ID`. */
fun insertEvent(form: EventForm): Long
/**
* Update an existing event (for recurring events: the whole series) to
* match [updated]. [original] is the form as it was prefilled from the
* event, so only fields the user actually changed are written and the
* reminder rows can be diffed instead of wiped.
*/
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
/**
* Change a single occurrence of a recurring event by inserting a
* modified-occurrence exception at [beginMillis] (the occurrence's
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
* row's `Events._ID`.
*/
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
/**
* Change a recurring event from the occurrence at [beginMillis] onwards
* by splitting the series: the existing RRULE ends just before the
* occurrence and a new event with [updated]'s values (and rule) starts
* there; returns the new event's `Events._ID`. From the first occurrence
* this is a plain series update. A carried-over COUNT restarts counting
* in the new series (we don't recompute the remaining occurrences).
*/
fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long
/**
* Delete a recurring event from the occurrence at [beginMillis] onwards
* by ending the series RRULE just before it. Deleting from the first
* occurrence removes the whole event.
*/
fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
/** Delete the whole event (for recurring events: the entire series). */
fun deleteEvent(eventId: Long)
@@ -101,7 +141,14 @@ class AndroidCalendarDataSource @Inject constructor(
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
put(CalendarContract.Events.DTEND, times.dtEndMillis)
// The provider's invariant: recurring rows carry RRULE+DURATION
// (and no DTEND), one-off rows carry DTEND.
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
@@ -128,6 +175,186 @@ class AndroidCalendarDataSource @Inject constructor(
return eventId
}
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
val values = buildEventUpdateValues(
original = original,
updated = updated,
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
zone = ZoneId.systemDefault(),
)
if (values.isNotEmpty()) {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update event id=$eventId")
}
// Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about.
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, updated.reminders)
}
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
// The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues(
form = form,
originalInstanceMillis = beginMillis,
zone = ZoneId.systemDefault(),
)
val uri = resolver.insert(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId),
values.toContentValues(),
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
val exceptionId = ContentUris.parseId(uri)
// Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, form.reminders)
return exceptionId
}
override fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long {
val row = querySeriesRow(eventId)
// From the first occurrence on (or with no rule to split) this is
// just a series update.
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
updateEvent(eventId, original, updated)
return eventId
}
// Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated)
truncateSeries(eventId, row, beginMillis)
return newEventId
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId)
// From the first occurrence on = the whole series; also the fallback
// when there is no RRULE to truncate.
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
deleteEvent(eventId)
return
}
truncateSeries(eventId, row, beginMillis)
}
/**
* End [row]'s series just before the occurrence at [beginMillis]. The
* provider regenerates an event's cached instances only from the values
* carried by the update itself — an RRULE-only update leaves the old
* instances standing (observed on-device: the truncated occurrence kept
* showing) — so the entire time-related set travels together, with only
* the RRULE actually changing.
*/
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
val values = ContentValues().apply {
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
put(
CalendarContract.Events.RRULE,
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
)
}
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values,
null, null,
)
if (rows == 0) {
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
}
}
/** The series anchor: every time-related column of the Events row. */
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
arrayOf(
CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
),
null, null, null,
)?.use { c ->
if (c.moveToFirst()) {
SeriesRow(
dtStartMillis = c.getLong(0),
rrule = c.getString(1),
timezone = c.getString(2),
duration = c.getString(3),
allDay = c.getInt(4),
)
} else {
null
}
} ?: throw WriteFailedException("read series row of event id=$eventId")
private data class SeriesRow(
val dtStartMillis: Long,
val rrule: String?,
val timezone: String?,
val duration: String?,
val allDay: Int,
) {
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis,
zone = runCatching { ZoneId.of(timezone ?: "UTC") }.getOrDefault(ZoneOffset.UTC),
)
}
/**
* Make the event's reminder rows match [targetMinutes]: rows with other
* lead times are deleted, missing ones inserted as best-effort ALERTs
* (like insertEvent). Rows whose minutes survive keep their method.
*/
private fun reconcileReminders(eventId: Long, targetMinutes: List<Int>) {
val target = targetMinutes.toSet()
val existing = queryReminders(eventId).map { it.minutes }.toSet()
(existing - target).forEach { minutes ->
resolver.delete(
CalendarContract.Reminders.CONTENT_URI,
CalendarContract.Reminders.EVENT_ID + " = ? AND " +
CalendarContract.Reminders.MINUTES + " = ?",
arrayOf(eventId.toString(), minutes.toString()),
)
}
(target - existing).forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
}
private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv ->
forEach { (column, value) ->
when (value) {
null -> cv.putNull(column)
is String -> cv.put(column, value)
is Long -> cv.put(column, value)
is Int -> cv.put(column, value)
else -> error("Unsupported value for $column: $value")
}
}
}
override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),

View File

@@ -15,11 +15,37 @@ interface CalendarRepository {
/** Create a new event from a validated form; returns the new `Events._ID`. */
suspend fun createEvent(form: EventForm): Long
/**
* Update an event (recurring: the whole series) from a validated form.
* [original] is the prefilled form, used to write only what changed.
*/
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
/**
* Change a single occurrence of a recurring event (exception row with the
* form's values); returns the exception's `Events._ID`.
*/
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
/**
* Change a recurring event from [beginMillis] onwards (series split);
* returns the new event's `Events._ID`.
*/
suspend fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long
/** Delete the whole event (for recurring events: the entire series). */
suspend fun deleteEvent(eventId: Long)
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
}
class NoSuchEventException(eventId: Long) :

View File

@@ -74,13 +74,45 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.insertEvent(form)
}
override suspend fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
) = withContext(io) {
dataSource.updateEvent(eventId, original, updated)
}
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
dataSource.deleteEvent(eventId)
}
override suspend fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
): Long = withContext(io) {
dataSource.updateOccurrence(eventId, beginMillis, form)
}
override suspend fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
}
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
dataSource.deleteOccurrence(eventId, beginMillis)
}
override suspend fun deleteEventFromOccurrence(
eventId: Long,
beginMillis: Long,
) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
}
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {

View File

@@ -42,8 +42,9 @@ internal fun ColumnReader.toEventDetailCore(
rawEnd
}
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
// Kept raw (no untitled fallback): the detail screen substitutes its own
// localized placeholder, and the edit form must prefill the true value.
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)

View File

@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@@ -37,6 +38,118 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
)
}
/**
* RFC 2445 duration for a recurring event's row (the provider requires
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
* events, seconds otherwise.
*/
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
} else {
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
}
/**
* Dirty-checked column values for updating an existing Events row: only what
* the user actually changed is written, so untouched fields can't stomp
* concurrent external edits. Keys are `CalendarContract.Events` columns; a
* null value means "set the column to NULL". An empty map means nothing on
* the row changed.
*
* Time fields travel together (the provider validates them as a unit):
* - unchanged times, all-day flag and rrule → no time columns at all;
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
* - recurring result → the *series* DTSTART moves by the same delta the user
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
* past occurrences intact when someone edits a later occurrence's time.
*/
internal fun buildEventUpdateValues(
original: EventForm,
updated: EventForm,
seriesDtStartMillis: Long,
zone: ZoneId,
): Map<String, Any?> = buildMap {
if (updated.title.trim() != original.title.trim()) {
put(CalendarContract.Events.TITLE, updated.title.trim())
}
if (updated.location.trim() != original.location.trim()) {
put(CalendarContract.Events.EVENT_LOCATION, updated.location.trim().ifEmpty { null })
}
if (updated.description.trim() != original.description.trim()) {
put(CalendarContract.Events.DESCRIPTION, updated.description.trim().ifEmpty { null })
}
if (updated.availability != original.availability) {
put(CalendarContract.Events.AVAILABILITY, updated.availability.toProviderValue())
}
if (updated.accessLevel != original.accessLevel) {
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
}
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule
if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone)
put(CalendarContract.Events.ALL_DAY, if (updated.isAllDay) 1 else 0)
put(CalendarContract.Events.EVENT_TIMEZONE, newTimes.timezone)
if (updated.rrule == null) {
put(CalendarContract.Events.DTSTART, newTimes.dtStartMillis)
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null)
} else {
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
}
}
/**
* Column values for a modified-occurrence exception row ("edit only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
* provider clone the series row and apply these on top. Unlike the series
* update there is no dirty check — the exception is a fresh row, so every
* form-backed column is written (empty optionals as explicit NULLs, since the
* clone starts from the parent's values). An exception is a single event:
* DTEND, never RRULE/DURATION.
*/
internal fun buildOccurrenceExceptionValues(
form: EventForm,
originalInstanceMillis: Long,
zone: ZoneId,
): Map<String, Any?> = buildMap {
val times = form.toWriteTimes(zone)
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
put(CalendarContract.Events.DTEND, times.dtEndMillis)
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
}
/**
* UTC millis of the last second of the local day *before* the occurrence at
* [beginMillis] in [zone] — the cutoff for truncating a series with UNTIL.
* The provider's recurrence engine applies UNTIL coarsely (observed on a
* Pixel: an occurrence one second *after* UNTIL was still generated), so the
* series must end on the previous day, not one second before the occurrence.
* With no sub-daily frequencies that is semantically the same cut.
*/
internal fun previousLocalDayEndUtcMillis(beginMillis: Long, zone: ZoneId): Long =
Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate()
.atStartOfDay(zone).toInstant().toEpochMilli() - 1_000L
private const val MILLIS_PER_DAY = 86_400_000L
internal fun Availability.toProviderValue(): Int = when (this) {
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE

View File

@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@@ -37,6 +39,12 @@ abstract class DataBindModule {
abstract fun bindCalendarRepository(
impl: CalendarRepositoryImpl,
): CalendarRepository
@Binds
@Singleton
abstract fun bindReminderAlertStore(
impl: AndroidReminderAlertStore,
): ReminderAlertStore
}
@Module

View File

@@ -86,6 +86,31 @@ class SettingsPrefs @Inject constructor(
}
}
/**
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the
* onboarding step and Settings warn about duplicates from a second app.
*/
val remindersEnabled: Flow<Boolean> = store.data.map { prefs ->
prefs[REMINDERS_ENABLED_KEY] ?: true
}
suspend fun setRemindersEnabled(enabled: Boolean) {
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
}
/**
* Whether the one-time reminder onboarding step (after the calendar
* grant) has been shown — also true for users who tapped "not now".
*/
val reminderOnboardingDone: Flow<Boolean> = store.data.map { prefs ->
prefs[REMINDER_ONBOARDING_KEY] ?: false
}
suspend fun setReminderOnboardingDone() {
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
}
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
@@ -98,6 +123,8 @@ class SettingsPrefs @Inject constructor(
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
}

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts
* `EVENT_REMINDER` at reminder time but posts nothing itself).
*
* The broadcast's data URI only carries the alarm time, so it is ignored:
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
* them, and mark them fired. Posting happens before marking — a crash in
* between re-posts silently (same tag) rather than losing the reminder.
*/
@AndroidEntryPoint
class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
val readGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (!readGranted || !notifier.canPost()) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
due.forEach(notifier::post)
alertStore.markFired(due.map { it.alertId }, now)
}
} finally {
pendingResult.finish()
}
}
}
}

View File

@@ -0,0 +1,112 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* One due row of the provider's `CalendarAlerts` table (a join with Events).
* Stays in the data layer: alerts feed the notification path only and never
* reach a screen, so there is no domain model for them.
*/
data class ReminderAlert(
val alertId: Long,
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
* without a ContentResolver. The provider creates these rows itself — only
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
* email reminders never show up here.
*/
interface ReminderAlertStore {
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
/**
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
* which the user may have declined — then re-broadcasts silently replace
* the already-posted notifications instead (same tag, alert-once).
*/
fun markFired(alertIds: List<Long>, nowMillis: Long)
}
@Singleton
class AndroidReminderAlertStore @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderAlertStore {
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
CalendarContract.CalendarAlerts.CONTENT_URI,
PROJECTION,
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
arrayOf(
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
nowMillis.toString(),
),
CalendarContract.CalendarAlerts.BEGIN + " ASC",
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderAlert(
alertId = c.getLong(0),
eventId = c.getLong(1),
beginMillis = c.getLong(2),
endMillis = c.getLong(3),
title = c.getString(4).orEmpty(),
location = c.getString(5)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(6) == 1,
),
)
}
}
} ?: emptyList()
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
if (alertIds.isEmpty()) return
val values = ContentValues().apply {
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
}
try {
context.contentResolver.update(
CalendarContract.CalendarAlerts.CONTENT_URI,
values,
CalendarContract.CalendarAlerts._ID +
" IN (" + alertIds.joinToString(",") + ")",
null,
)
} catch (e: SecurityException) {
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
}
}
private companion object {
const val TAG = "ReminderAlertStore"
val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE,
CalendarContract.CalendarAlerts.EVENT_LOCATION,
CalendarContract.CalendarAlerts.ALL_DAY,
)
}
}

View File

@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import java.time.ZoneId
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per due reminder alert on a dedicated channel.
* Tapping opens the event's detail screen; the tag is the alert id, so a
* re-broadcast of an alert we couldn't mark fired replaces its notification
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
* duplicating it.
*/
@Singleton
class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
fun post(alert: ReminderAlert) {
ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val time = reminderTimeText(
beginMillis = alert.beginMillis,
endMillis = alert.endMillis,
isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(),
locale = Locale.getDefault(),
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setWhen(alert.beginMillis)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(detailIntent(alert))
.build()
try {
NotificationManagerCompat.from(context)
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
}
}
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
/* requestCode = */ alert.alertId.toInt(),
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Channel creation is idempotent; re-running refreshes the localized name. */
private fun ensureChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reminder_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.reminder_channel_description)
}
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
private companion object {
const val TAG = "ReminderNotifier"
const val CHANNEL_ID = "reminders"
// One id, distinct tags: the tag (alert id) already keys the
// notification, so a fixed id keeps cancellation/replacement simple.
const val NOTIFICATION_ID = 1
}
}

View File

@@ -0,0 +1,56 @@
package de.jeanlucmakiola.calendula.data.reminders
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* The one line of time context in a reminder notification. Pure so it can be
* JVM-tested:
*
* - timed, same day: "09:30 10:00"
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 12 Jun 2026"
*
* All-day instances store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
*/
fun reminderTimeText(
beginMillis: Long,
endMillis: Long,
isAllDay: Boolean,
zone: ZoneId,
locale: Locale,
): String {
if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
return if (lastDay == firstDay) {
dateFormat.format(firstDay)
} else {
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
}
}
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
return if (begin.toLocalDate() == end.toLocalDate()) {
timeFormat.format(begin) + RANGE + timeFormat.format(end)
} else {
val dateTimeFormat = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.withLocale(locale)
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
}
}
private const val RANGE = " "

View File

@@ -1,6 +1,11 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
* User input for creating an event (and, from v1.3, editing one). Times are
@@ -19,6 +24,12 @@ data class EventForm(
val reminders: List<Int> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
/**
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
* means a one-off event. May hold rules the simple picker can't express —
* those are kept verbatim until the user picks something else.
*/
val rrule: String? = null,
)
/**
@@ -29,6 +40,7 @@ enum class EventFormField {
Location,
Description,
Reminders,
Recurrence,
Availability,
Visibility,
}
@@ -37,6 +49,8 @@ enum class EventFormProblem {
/** No target calendar — none picked and no writable calendar exists. */
NoCalendar,
EndBeforeStart,
/** The recurrence's UNTIL date lies before the event's first day. */
RecurrenceEndsBeforeStart,
}
/**
@@ -44,8 +58,88 @@ enum class EventFormProblem {
* allowed (display falls back to "(No title)", matching the provider), and a
* zero-length timed event is allowed (spec §8: instant events exist).
*/
/**
* Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the
* tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series
* start — the data layer later turns a time edit into a delta on the series.
*
* All-day provider times are UTC midnights with an exclusive end; the form
* shows the last covered day and keeps placeholder wall-clock times in case
* the user switches the event to timed.
*/
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
val (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
}
return EventForm(
calendarId = instance.calendarId,
title = instance.title,
isAllDay = instance.isAllDay,
start = start,
end = end,
location = instance.location.orEmpty(),
description = description.orEmpty(),
reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability,
accessLevel = accessLevel,
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)
}
/**
* What the edit form saw when it loaded — compared against a fresh read at
* save time to detect external changes (sync, another device) that landed
* while the form was open. The raw row times ride along because
* [toEditForm] derives the form's times from the *tapped occurrence*, so
* re-deriving with the same occurrence would mask an externally moved
* event. Not covered (the form can't write them, and the dirty-checked
* write can't clobber them): attendees, status, the user's own response,
* reminder methods, and a recurring event's duration.
*/
data class EditSnapshot(
val form: EventForm,
/** The raw Events-row times (for recurring events: the series anchor). */
val rowStart: Instant,
val rowEnd: Instant,
)
fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZone): EditSnapshot =
EditSnapshot(
form = toEditForm(beginMillis, endMillis, zone),
rowStart = instance.start,
rowEnd = instance.end,
)
/**
* The optional sections that hold a value in [form] — when editing, these
* must be visible regardless of the user's default-fields setting, or the
* data they carry would be invisible (though still preserved).
*/
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description)
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
}
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar)
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
// An UNTIL before the first day would make the provider generate zero
// occurrences — the event would silently vanish from every view.
val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end
if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) {
add(EventFormProblem.RecurrenceEndsBeforeStart)
}
}

View File

@@ -115,6 +115,16 @@ enum class AccessLevel {
Confidential,
}
/**
* How far a write to a recurring event reaches. Non-recurring events always
* use [AllEvents] (there is only one).
*/
enum class RecurringWriteScope {
ThisEvent,
ThisAndFollowing,
AllEvents,
}
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,

View File

@@ -0,0 +1,197 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
* The recurrence shapes the simple picker can express (v1.3): a frequency,
* an interval, weekly weekday picks, and an optional end. Anything beyond
* that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw
* RRULE string the picker shows as "custom" and leaves untouched unless the
* user replaces it.
*/
data class SimpleRecurrence(
val freq: RecurrenceFreq,
val interval: Int = 1,
val end: RecurrenceEnd = RecurrenceEnd.Never,
/**
* Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means
* no BYDAY part — the provider derives the day from DTSTART.
*/
val byDays: Set<DayOfWeek> = emptySet(),
)
enum class RecurrenceFreq {
Daily,
Weekly,
Monthly,
Yearly,
}
sealed interface RecurrenceEnd {
data object Never : RecurrenceEnd
/** Last day on which an occurrence may fall (inclusive). */
data class Until(val date: LocalDate) : RecurrenceEnd
/** Total number of occurrences, counting the first. */
data class Count(val times: Int) : RecurrenceEnd
}
/**
* Parse an RRULE into the picker's simple shape, or null when the rule uses
* parts the picker can't represent (so the UI preserves the original string).
* Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime
* UNTIL is converted from UTC into [zone] before its date is taken, mirroring
* [toRRule].
*/
fun parseSimpleRecurrence(
rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? {
val parts = rrule.removePrefix("RRULE:").split(';')
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null
}
val freq = when (parts["FREQ"]?.uppercase()) {
"DAILY" -> RecurrenceFreq.Daily
"WEEKLY" -> RecurrenceFreq.Weekly
"MONTHLY" -> RecurrenceFreq.Monthly
"YEARLY" -> RecurrenceFreq.Yearly
else -> return null
}
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
// forms ("2TH" = second Thursday) and BYDAY on other frequencies are not.
val byDays = parts["BYDAY"]?.let { raw ->
if (freq != RecurrenceFreq.Weekly) return null
raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet()
} ?: emptySet()
val until = parts["UNTIL"]
val count = parts["COUNT"]
if (until != null && count != null) return null
val end = when {
until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null)
count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null)
else -> RecurrenceEnd.Never
}
return SimpleRecurrence(freq, interval, end, byDays)
}
/**
* Render as a provider-ready RRULE value (no "RRULE:" prefix —
* `CalendarContract.Events.RRULE` stores the bare value). UNTIL is written as
* the end of the chosen day *in [zone]*, expressed in UTC: the recurrence
* engine has been observed applying UNTIL coarsely after converting it into
* the event's timezone, so a plain `T235959Z` can leak one extra day for
* zones ahead of UTC.
*/
fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString {
append("FREQ=")
append(
when (freq) {
RecurrenceFreq.Daily -> "DAILY"
RecurrenceFreq.Weekly -> "WEEKLY"
RecurrenceFreq.Monthly -> "MONTHLY"
RecurrenceFreq.Yearly -> "YEARLY"
},
)
if (interval > 1) append(";INTERVAL=$interval")
if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) {
append(";BYDAY=")
append(
byDays.sortedBy { it.isoDayNumber }
.joinToString(",") { RRULE_DAY_CODES.getValue(it) },
)
}
when (val e = end) {
RecurrenceEnd.Never -> Unit
is RecurrenceEnd.Until -> {
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
.toInstant(zone)
.toLocalDateTime(TimeZone.UTC)
append(
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.month.number, utc.day,
utc.hour, utc.minute, utc.second,
),
)
}
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
}
}
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU",
DayOfWeek.WEDNESDAY to "WE",
DayOfWeek.THURSDAY to "TH",
DayOfWeek.FRIDAY to "FR",
DayOfWeek.SATURDAY to "SA",
DayOfWeek.SUNDAY to "SU",
)
/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */
private fun rruleDay(token: String): DayOfWeek? =
RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key
/**
* End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing
* UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives.
* Used for "delete this and all following occurrences" — the caller passes a
* moment just before the first occurrence to remove.
*/
fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
val kept = rrule.removePrefix("RRULE:").split(';')
.filter { it.isNotBlank() }
.filterNot { part ->
val key = part.substringBefore('=').trim().uppercase()
key == "UNTIL" || key == "COUNT"
}
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
until.year, until.month.number, until.day,
until.hour, until.minute, until.second,
)
return (kept + untilPart).joinToString(";")
}
/**
* Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
* a [toRRule]-rendered value round-trips to the day the user picked.
*/
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
val date = LocalDate(
raw.substring(0, 4).toInt(),
raw.substring(4, 6).toInt(),
raw.substring(6, 8).toInt(),
)
if (raw.length >= 15 && raw[8] == 'T') {
val time = LocalTime(
raw.substring(9, 11).toInt(),
raw.substring(11, 13).toInt(),
raw.substring(13, 15).toInt(),
)
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
} else {
date
}
}.getOrNull()

View File

@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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
@@ -29,9 +30,18 @@ import kotlinx.datetime.LocalDate
* Holds the active top-level view (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*
* [requestedDetailKey] is an externally requested occurrence (a tapped
* reminder notification routed through MainActivity): it opens the detail
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
* so a later recomposition can't re-open it.
*/
@Composable
fun CalendarHost(modifier: Modifier = Modifier) {
fun CalendarHost(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -61,6 +71,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
detailKey = key
}
// A tapped reminder notification asks for a specific occurrence.
LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) {
heldKey = requestedDetailKey
detailKey = requestedDetailKey
onDetailKeyConsumed()
}
}
// Settings (M4) is hoisted here so it overlays whichever calendar view is
// active and survives view switches. (The calendar filter now lives inline
// in the navigation drawer, so no overlay state is needed for it.)
@@ -76,6 +95,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
createDateIso = date.toString()
}
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
// recurring events the form itself asks for the write scope at save
// time. A saved edit closes the detail screen too: the occurrence the
// user tapped may not exist anymore (time moved, recurrence changed), so
// falling back to the auto-refreshing calendar is the only honest
// destination.
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
val slideSpec = rememberCalendarSlideSpec()
Box(modifier = modifier.fillMaxSize()) {
@@ -117,6 +145,10 @@ fun CalendarHost(modifier: Modifier = Modifier) {
beginMillis = key[1],
endMillis = key[2],
onBack = { detailKey = null },
onEdit = {
heldEditKey = key
editKey = key
},
)
}
}
@@ -131,6 +163,26 @@ fun CalendarHost(modifier: Modifier = Modifier) {
EventEditScreen(
initialDateIso = iso,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
)
}
}
// Edit form (v1.3) — slides over the detail screen.
AnimatedVisibility(
visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
(editKey ?: heldEditKey)?.let { key ->
EventEditScreen(
initialDateIso = null,
editKey = key,
onClose = { editKey = null },
onSaved = {
editKey = null
detailKey = null
},
)
}
}

View File

@@ -10,14 +10,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@Composable
fun RootScreen(modifier: Modifier = Modifier) {
fun RootScreen(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
@@ -40,7 +48,23 @@ fun RootScreen(modifier: Modifier = Modifier) {
}
if (hasPermission) {
CalendarHost(modifier = modifier)
// Second onboarding gate (v1.4, one-time): reminder notifications.
// Null until DataStore's first emission — render nothing for that
// frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
when (onboardingDone) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },

View File

@@ -0,0 +1,126 @@
package de.jeanlucmakiola.calendula.ui.common
import android.icu.text.ListFormatter
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/**
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
* Falls back to a generic label for rules we don't render in full (ordinal
* monthly/yearly BYDAY, etc.). Shared by the detail screen and the edit
* form's repeat card.
*/
@Composable
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
val base = when (freq) {
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
else stringResource(R.string.recurrence_every_n_days, interval)
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
else stringResource(R.string.recurrence_every_n_weeks, interval)
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
else stringResource(R.string.recurrence_every_n_months, interval)
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
else stringResource(R.string.recurrence_every_n_years, interval)
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
}
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
// The day names + their joined block are tracked so only the names (not the
// commas/conjunction) can be italicised in the final string.
val byDay = parts["BYDAY"]
var dayNames: List<String>? = null
var joinedDays: String? = null
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
if (days.isNotEmpty()) {
val joined = ListFormatter.getInstance(locale).format(days)
dayNames = days
joinedDays = joined
stringResource(R.string.recurrence_on_days, base, joined)
} else {
base
}
} else {
base
}
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
val count = parts["COUNT"]?.toIntOrNull()
val full = when {
until != null -> stringResource(R.string.recurrence_with_until, main, until)
count != null -> stringResource(R.string.recurrence_with_count, main, count)
else -> main
}
return buildAnnotatedString {
append(full)
val names = dayNames
val joined = joinedDays
if (names != null && joined != null) {
// Italicise each day name within the joined block only — leaving the
// separators and conjunction ("und"/"and") in the regular style.
val regionStart = full.indexOf(joined)
if (regionStart >= 0) {
val regionEnd = regionStart + joined.length
var cursor = regionStart
for (name in names) {
val at = full.indexOf(name, cursor)
if (at in regionStart until regionEnd) {
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
cursor = at + name.length
}
}
}
}
}
}
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
"SU" -> DayOfWeek.SUNDAY
else -> return null
}
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
}
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() }
if (digits.length < 8) return null
return try {
val date = java.time.LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
} catch (e: Exception) {
null
}
}

View File

@@ -5,7 +5,6 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.icu.text.ListFormatter
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -35,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
@@ -74,7 +74,6 @@ import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
@@ -90,13 +89,14 @@ import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.TimeZone
import java.time.DayOfWeek
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -109,7 +109,9 @@ import kotlin.time.Instant
* Full-screen event detail (spec S4, realised as a navigation destination
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
* top-bar arrow both return to the calendar. Events in writable calendars can
* be deleted from here (v1.1); edit follows in v1.3.
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
* event form for this occurrence — for recurring events the form asks how
* far the change reaches when saving.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -118,6 +120,7 @@ fun EventDetailScreen(
beginMillis: Long,
endMillis: Long,
onBack: () -> Unit,
onEdit: () -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -133,20 +136,35 @@ fun EventDetailScreen(
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the confirm dialog.
// upgrade in place. Granting continues straight into the tapped action.
var pendingEdit by remember { mutableStateOf(false) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) showDeleteDialog = true
if (granted) {
if (pendingEdit) onEdit() else showDeleteDialog = true
}
pendingEdit = false
}
val onDeleteClick = {
val granted = ContextCompat.checkSelfPermission(
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
}
val onDeleteClick = {
if (hasWritePermission()) {
showDeleteDialog = true
} else {
pendingEdit = false
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val onEditClick = {
if (hasWritePermission()) {
onEdit()
} else {
pendingEdit = true
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
@@ -189,6 +207,15 @@ fun EventDetailScreen(
// birthday calendars etc. are read-only at the provider level.
val s = state
if (s is EventDetailUiState.Success && s.canModify) {
IconButton(
onClick = onEditClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(R.string.event_detail_edit),
)
}
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
@@ -223,9 +250,9 @@ fun EventDetailScreen(
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
DeleteEventDialog(
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
onConfirm = { wholeSeries ->
onConfirm = { scope ->
showDeleteDialog = false
viewModel.delete(wholeSeries)
viewModel.delete(scope)
},
onDismiss = { showDeleteDialog = false },
)
@@ -234,15 +261,16 @@ fun EventDetailScreen(
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default) and removing the whole series.
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (wholeSeries: Boolean) -> Unit,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var wholeSeries by rememberSaveable { mutableStateOf(false) }
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -258,13 +286,18 @@ private fun DeleteEventDialog(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { wholeSeries = false },
selected = !wholeSeries,
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { wholeSeries = true },
selected = wholeSeries,
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
@@ -272,7 +305,9 @@ private fun DeleteEventDialog(
}
},
confirmButton = {
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
@@ -707,116 +742,6 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remem
}
}
/**
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
* Falls back to a generic label for rules we don't render in full (ordinal
* monthly/yearly BYDAY, etc.).
*/
@Composable
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
val base = when (freq) {
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
else stringResource(R.string.recurrence_every_n_days, interval)
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
else stringResource(R.string.recurrence_every_n_weeks, interval)
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
else stringResource(R.string.recurrence_every_n_months, interval)
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
else stringResource(R.string.recurrence_every_n_years, interval)
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
}
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
// The day names + their joined block are tracked so only the names (not the
// commas/conjunction) can be italicised in the final string.
val byDay = parts["BYDAY"]
var dayNames: List<String>? = null
var joinedDays: String? = null
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
if (days.isNotEmpty()) {
val joined = ListFormatter.getInstance(locale).format(days)
dayNames = days
joinedDays = joined
stringResource(R.string.recurrence_on_days, base, joined)
} else {
base
}
} else {
base
}
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
val count = parts["COUNT"]?.toIntOrNull()
val full = when {
until != null -> stringResource(R.string.recurrence_with_until, main, until)
count != null -> stringResource(R.string.recurrence_with_count, main, count)
else -> main
}
return buildAnnotatedString {
append(full)
val names = dayNames
val joined = joinedDays
if (names != null && joined != null) {
// Italicise each day name within the joined block only — leaving the
// separators and conjunction ("und"/"and") in the regular style.
val regionStart = full.indexOf(joined)
if (regionStart >= 0) {
val regionEnd = regionStart + joined.length
var cursor = regionStart
for (name in names) {
val at = full.indexOf(name, cursor)
if (at in regionStart until regionEnd) {
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
cursor = at + name.length
}
}
}
}
}
}
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
"SU" -> DayOfWeek.SUNDAY
else -> return null
}
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
}
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() }
if (digits.length < 8) return null
return try {
val date = java.time.LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
} catch (e: Exception) {
null
}
}
/**
* Format an event's time into a primary line (date, or "All day") and an
* optional secondary line (time range). Multi-day timed events collapse into a

View File

@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -78,20 +79,23 @@ class EventDetailViewModel @Inject constructor(
}
/**
* Delete the open event. [wholeSeries] is meaningful only for recurring
* events: false cancels just the tapped occurrence. Result lands in
* [deleteState]; the screen consumes it via [consumeDeleteResult].
* Delete the open event. [scope] is meaningful only for recurring events
* (one-off events always pass [RecurringWriteScope.AllEvents]). Result
* lands in [deleteState]; the screen consumes it via [consumeDeleteResult].
*/
fun delete(wholeSeries: Boolean) {
fun delete(scope: RecurringWriteScope) {
val target = _target.value ?: return
if (_deleteState.value == DeleteUiState.Deleting) return
viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting
_deleteState.value = try {
if (wholeSeries) {
repository.deleteEvent(target.eventId)
} else {
repository.deleteOccurrence(target.eventId, target.beginMillis)
when (scope) {
RecurringWriteScope.AllEvents ->
repository.deleteEvent(target.eventId)
RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted
} catch (e: CancellationException) {

View File

@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
@@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.VisibilityOff
@@ -88,6 +90,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -101,49 +104,82 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
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.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinDayOfWeek
import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.time.temporal.WeekFields
import java.util.Locale
import kotlin.time.Clock
/**
* Full-screen event form (v1.2: create only). Opens prefilled from the FAB's
* anchor date; Save validates, writes via the repository, and closes. The
* calendar picker offers only writable calendars.
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
* (v1.3, [editKey] = eventId + the tapped occurrence's begin/end millis).
* Save validates and writes via the repository — a dirty recurring event
* first asks how far the change reaches (this occurrence / this and
* following / whole series) — then closes. The calendar picker offers only
* writable calendars; when editing, the calendar is fixed.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventEditScreen(
initialDateIso: String?,
onClose: () -> Unit,
onSaved: () -> Unit,
editKey: LongArray? = null,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso) {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date)
LaunchedEffect(initialDateIso, editKey) {
if (editKey != null) {
viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
endMillis = editKey[2],
)
} else {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date)
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
// The form is intentionally forgotten on every close (cancel or save) so
// the next FAB tap starts clean; it survives rotation because openNew
// no-ops while a form is set.
// the next open starts clean; it survives rotation because openNew /
// openForEdit no-op while a form is set.
val close = {
viewModel.reset()
onClose()
}
BackHandler(onBack = close)
// The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) {
if (loadFailed) close()
}
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@@ -169,7 +205,10 @@ fun EventEditScreen(
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
LaunchedEffect(state?.saveState) {
when (state?.saveState) {
SaveUiState.Saved -> close()
SaveUiState.Saved -> {
viewModel.reset()
onSaved()
}
SaveUiState.Failed -> {
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(saveFailedMessage)
@@ -178,7 +217,8 @@ fun EventEditScreen(
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(writeDeniedMessage)
}
SaveUiState.Idle, SaveUiState.Saving, null -> Unit
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
else -> Unit
}
}
@@ -222,6 +262,115 @@ fun EventEditScreen(
)
}
}
if (state?.saveState == SaveUiState.AwaitingScope) {
SaveScopeDialog(
recurrenceChanged = state?.recurrenceChanged == true,
onSelect = viewModel::saveWithScope,
onDismiss = viewModel::consumeSaveResult,
)
}
// The event changed externally (sync) while the form was open (v2.0).
if (state?.saveState is SaveUiState.AwaitingConflict) {
SaveConflictDialog(
onOverwrite = viewModel::saveOverwriting,
onDiscard = close,
onDismiss = viewModel::consumeSaveResult,
)
}
// ...or was deleted underneath us — nothing left to save onto. Closing
// through [onSaved] also pops the detail screen, whose occurrence is gone.
if (state?.saveState == SaveUiState.Gone) {
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.event_edit_gone_title)) },
text = { Text(stringResource(R.string.event_edit_gone_body)) },
confirmButton = {
TextButton(onClick = {
viewModel.reset()
onSaved()
}) { Text(stringResource(R.string.dialog_ok)) }
},
)
}
}
/**
* Overwrite-or-discard choice when the event changed underneath an open
* form (no locking; detected by re-reading at save time). "Overwrite" still
* only writes the fields the user edited — external changes to untouched
* fields survive either way. Cancelling returns to the form.
*/
@Composable
private fun SaveConflictDialog(
onOverwrite: () -> Unit,
onDiscard: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.event_edit_conflict_body))
Spacer(Modifier.height(4.dp))
OptionCard(
label = stringResource(R.string.event_edit_conflict_overwrite),
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
onClick = onOverwrite,
)
OptionCard(
label = stringResource(R.string.event_edit_conflict_discard),
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
onClick = onDiscard,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/**
* Scope choice when saving a dirty recurring event: one tap writes just the
* tapped occurrence, it and everything after (series split), or the whole
* series. A changed recurrence rule rules out the single occurrence — an
* exception row can't carry its own rule.
*/
@Composable
private fun SaveScopeDialog(
recurrenceChanged: Boolean,
onSelect: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!recurrenceChanged) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
)
}
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { onSelect(RecurringWriteScope.AllEvents) },
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
@@ -261,6 +410,7 @@ private fun EventEditContent(
var picker by remember { mutableStateOf<PickerTarget?>(null) }
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
@@ -348,12 +498,15 @@ private fun EventEditContent(
Spacer(Modifier.height(gap))
// Calendar card — tap anywhere to pick the target calendar.
// Calendar card — tap anywhere to pick the target calendar. Editing
// keeps the owning calendar (moving events between calendars is a
// sync-adapter minefield; every stock calendar app locks it too).
EditCard(
icon = Icons.Default.CalendarMonth,
iconContentDescription = stringResource(R.string.event_detail_calendar),
iconTint = accent,
onClick = { showCalendarPicker = true }.takeIf { state.calendars.isNotEmpty() },
onClick = { showCalendarPicker = true }
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
) {
Text(
text = selectedCalendar?.displayName
@@ -438,6 +591,46 @@ private fun EventEditContent(
}
}
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.Default.Repeat,
iconContentDescription = null,
onClick = { showRecurrencePicker = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = form.rrule?.let { recurrenceText(it, locale) }
?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.event_detail_recurrence),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(R.string.event_edit_error_recurrence_ends_before_start),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
@@ -497,7 +690,7 @@ private fun EventEditContent(
}
}
OptionalFormSection(visible = state.hasHiddenFields) {
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
TextButton(
onClick = { showFieldPicker = true },
@@ -561,6 +754,18 @@ private fun EventEditContent(
)
}
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
startDay = form.start.date.dayOfWeek,
onSelect = { rrule ->
viewModel.setRecurrence(rrule)
showRecurrencePicker = false
},
onDismiss = { showRecurrencePicker = false },
)
}
if (showVisibilityPicker) {
VisibilityPickerDialog(
selected = form.accessLevel,
@@ -574,7 +779,7 @@ private fun EventEditContent(
if (showFieldPicker) {
FieldPickerDialog(
hiddenFields = EventFormField.entries.filterNot { it in state.visibleFields },
hiddenFields = state.hiddenFields,
onSelect = { field ->
viewModel.revealField(field)
showFieldPicker = false
@@ -660,71 +865,17 @@ private fun ReminderPickerDialog(
}
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
// surfaceContainerHighest — the dialog itself sits on
// surfaceContainerHigh, so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineField(
value = amountText,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) {
amountText = text
}
},
placeholder = "10",
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
DialogAmountField(
value = amountText,
onValueChange = { amountText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
var unitMenuOpen by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { unitMenuOpen = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
start = 14.dp,
end = 8.dp,
top = 12.dp,
bottom = 12.dp,
),
) {
Text(
text = stringResource(reminderUnitLabel(unit)),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
DropdownMenu(
expanded = unitMenuOpen,
onDismissRequest = { unitMenuOpen = false },
) {
ReminderUnit.entries.forEach { entry ->
DropdownMenuItem(
text = { Text(stringResource(reminderUnitLabel(entry))) },
onClick = {
unit = entry
unitMenuOpen = false
},
)
}
}
}
DialogUnitDropdown(
label = stringResource(reminderUnitLabel(unit)),
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
onPick = { unit = ReminderUnit.entries[it] },
)
}
}
},
@@ -743,6 +894,351 @@ private fun ReminderPickerDialog(
)
}
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
private enum class RecurrenceEndMode { Never, Until, Count }
/**
* Recurrence picker, two steps like the reminder picker: the plain
* frequencies as a tappable list (one tap applies and closes), with "Custom"
* switching to an interval-plus-unit editor, weekday toggles (weekly only)
* and an end condition — only that step needs an OK button. A rule the
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
* user picks something here.
*/
@Composable
private fun RecurrencePickerDialog(
current: String?,
startDay: DayOfWeek,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
val isPlainPreset = parsed != null && parsed.interval == 1 &&
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
var customMode by rememberSaveable { mutableStateOf(false) }
var intervalText by rememberSaveable { mutableStateOf((parsed?.interval ?: 1).toString()) }
var freq by rememberSaveable { mutableStateOf(parsed?.freq ?: RecurrenceFreq.Weekly) }
// The event's start weekday stands in until the user picks days herself.
var daysMask by rememberSaveable {
mutableStateOf(
(parsed?.byDays?.takeIf { it.isNotEmpty() } ?: setOf(startDay)).toMask(),
)
}
var endMode by rememberSaveable {
mutableStateOf(
when (parsed?.end) {
is RecurrenceEnd.Until -> RecurrenceEndMode.Until
is RecurrenceEnd.Count -> RecurrenceEndMode.Count
else -> RecurrenceEndMode.Never
},
)
}
var untilIso by rememberSaveable {
mutableStateOf((parsed?.end as? RecurrenceEnd.Until)?.date?.toString())
}
var countText by rememberSaveable {
mutableStateOf(((parsed?.end as? RecurrenceEnd.Count)?.times ?: 10).toString())
}
var showUntilPicker by rememberSaveable { mutableStateOf(false) }
val locale = currentLocale()
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
val customEnd: RecurrenceEnd? = when (endMode) {
RecurrenceEndMode.Never -> RecurrenceEnd.Never
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
}
val customResult: String? = if (interval != null && customEnd != null) {
SimpleRecurrence(
freq = freq,
interval = interval,
end = customEnd,
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
).toRRule()
} else {
null
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_detail_recurrence)) },
text = {
if (!customMode) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_edit_recurrence_none),
onClick = { onSelect(null) },
selected = current == null,
)
RecurrenceFreq.entries.forEach { entry ->
OptionCard(
label = stringResource(recurrencePresetLabel(entry)),
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
selected = isPlainPreset && parsed?.freq == entry,
)
}
OptionCard(
label = stringResource(R.string.event_edit_recurrence_custom),
onClick = { customMode = true },
selected = current != null && !isPlainPreset,
labelColor = MaterialTheme.colorScheme.primary,
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.event_edit_recurrence_every),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(12.dp))
DialogAmountField(
value = intervalText,
onValueChange = { intervalText = it },
placeholder = "1",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(recurrenceUnitLabel(freq)),
entries = RecurrenceFreq.entries.map {
stringResource(recurrenceUnitLabel(it))
},
onPick = { freq = RecurrenceFreq.entries[it] },
)
}
if (freq == RecurrenceFreq.Weekly) {
Spacer(Modifier.height(4.dp))
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_never),
onClick = { endMode = RecurrenceEndMode.Never },
selected = endMode == RecurrenceEndMode.Never,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_until),
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
supportingText = untilDate?.let {
remember(it, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
selected = endMode == RecurrenceEndMode.Until,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_count),
onClick = { endMode = RecurrenceEndMode.Count },
selected = endMode == RecurrenceEndMode.Count,
)
if (endMode == RecurrenceEndMode.Count) {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = countText,
onValueChange = { countText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_times),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
},
confirmButton = {
// The preset list applies on tap; only the custom step needs OK.
if (customMode) {
TextButton(
enabled = customResult != null,
onClick = { customResult?.let(onSelect) },
) { Text(stringResource(R.string.dialog_ok)) }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
if (showUntilPicker) {
DatePickerAlert(
initial = untilDate ?: LocalDate.fromEpochDays(
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
),
onConfirm = {
untilIso = it.toString()
showUntilPicker = false
},
onDismiss = { showUntilPicker = false },
)
}
}
/**
* One tappable circle per weekday (locale week order), multi-select — the
* BYDAY picks of a weekly rule. Deselecting every day is allowed; the rule
* then falls back to the event's start weekday (provider behaviour).
*/
@Composable
private fun WeekdayToggleRow(
selected: Set<DayOfWeek>,
onToggle: (DayOfWeek) -> Unit,
locale: Locale,
) {
val days = remember(locale) {
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
days.forEach { day ->
val isSelected = day in selected
Surface(
onClick = { onToggle(day) },
shape = CircleShape,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Text(
text = day.toJavaDayOfWeek().getDisplayName(JavaTextStyle.NARROW, locale),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
/** Weekday sets travel through rememberSaveable as an ISO-day bitmask. */
private fun DayOfWeek.toMaskBit(): Int = 1 shl (isoDayNumber - 1)
private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMaskBit() }
private fun Int.toDaySet(): Set<DayOfWeek> =
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
@Composable
private fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the dialog itself sits on
// surfaceContainerHigh, so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) {
onValueChange(text)
}
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
@Composable
private fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
start = 14.dp,
end = 8.dp,
top = 12.dp,
bottom = 12.dp,
),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Daily -> R.string.recurrence_daily
RecurrenceFreq.Weekly -> R.string.recurrence_weekly
RecurrenceFreq.Monthly -> R.string.recurrence_monthly
RecurrenceFreq.Yearly -> R.string.recurrence_yearly
}
private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Daily -> R.string.recurrence_unit_days
RecurrenceFreq.Weekly -> R.string.recurrence_unit_weeks
RecurrenceFreq.Monthly -> R.string.recurrence_unit_months
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
}
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
@Composable
private fun ReminderRow(label: String, onRemove: () -> Unit) {
@@ -793,6 +1289,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
}
@@ -801,6 +1298,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock
}

View File

@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
/**
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
@@ -19,13 +20,34 @@ data class EventEditUiState(
val saveState: SaveUiState,
/** Optional sections currently rendered (settings defaults revealed). */
val visibleFields: Set<EventFormField> = emptySet(),
/** True while at least one optional section hides behind "more fields". */
val hasHiddenFields: Boolean = false,
/**
* Optional sections behind "more fields". Sections the current mode can't
* offer at all (recurrence while editing a single occurrence) appear in
* neither list.
*/
val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false,
/**
* True while an edit changed the recurrence rule — the save-scope dialog
* then drops "only this event" (an exception row can't carry a rule).
*/
val recurrenceChanged: Boolean = false,
)
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
sealed interface SaveUiState {
data object Idle : SaveUiState
/** A dirty recurring event waits for the user to pick the write scope. */
data object AwaitingScope : SaveUiState
/**
* The event changed externally (sync) while the form was open; the save
* is parked with its chosen [scope] until the user picks overwrite,
* discard, or cancel.
*/
data class AwaitingConflict(val scope: RecurringWriteScope) : SaveUiState
/** The event was deleted externally while the form was open. */
data object Gone : SaveUiState
data object Saving : SaveUiState
data object Saved : SaveUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */

View File

@@ -4,17 +4,23 @@ 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.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -54,13 +60,38 @@ class EventEditViewModel @Inject constructor(
// form isn't already shouting errors.
private val _showProblems = MutableStateFlow(false)
// Fields added through the "more fields" picker; folds back on reset().
// openForEdit seeds it with the sections that already hold values.
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
// Set while the form edits an existing event instead of composing a new one.
private val _editTarget = MutableStateFlow<EditTarget?>(null)
private val _loadFailed = MutableStateFlow(false)
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
/**
* The event being edited plus everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the
* tapped occurrence's [beginMillis]/[endMillis] anchor occurrence-level
* writes and the conflict re-read. [zone] is pinned at load so a device
* timezone change mid-edit can't fake a conflict.
*/
private data class EditTarget(
val eventId: Long,
val snapshot: EditSnapshot,
val beginMillis: Long,
val endMillis: Long,
val zone: TimeZone,
) {
val original: EventForm get() = snapshot.form
}
private data class LocalInputs(
val form: EventForm?,
val saveState: SaveUiState,
val showProblems: Boolean,
val revealed: Set<EventFormField>,
val editTarget: EditTarget?,
)
private data class ExternalInputs(
@@ -70,7 +101,7 @@ class EventEditViewModel @Inject constructor(
)
val state: StateFlow<EventEditUiState?> = combine(
combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs),
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
combine(
repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
@@ -92,7 +123,12 @@ class EventEditViewModel @Inject constructor(
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hasHiddenFields = visibleFields.size < EventFormField.entries.size,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
// A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null &&
resolved.rrule != local.editTarget.original.rrule,
)
}
.flowOn(io)
@@ -123,12 +159,39 @@ class EventEditViewModel @Inject constructor(
_form.value = EventForm(calendarId = null, start = start, end = end)
}
/** Forget the open form; the next [openNew] starts clean. */
/**
* Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. No-op while a
* form is open, so user edits survive configuration changes.
*/
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return
viewModelScope.launch {
val detail = try {
repository.eventDetail(eventId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_loadFailed.value = true
return@launch
}
val zone = TimeZone.currentSystemDefault()
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
}
}
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() {
_form.value = null
_saveState.value = SaveUiState.Idle
_showProblems.value = false
_revealed.value = emptySet()
_editTarget.value = null
_loadFailed.value = false
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -144,6 +207,9 @@ class EventEditViewModel @Inject constructor(
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
fun addReminder(minutes: Int) = update {
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
}
@@ -158,7 +224,12 @@ class EventEditViewModel @Inject constructor(
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
/** Validate and write. Terminal results land in [saveState]. */
/**
* Validate and write. Saving a dirty recurring event pauses in
* [SaveUiState.AwaitingScope] until the screen answers via
* [saveWithScope]; everything else writes directly. Terminal results
* land in [saveState].
*/
fun save() {
val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return
@@ -167,11 +238,82 @@ class EventEditViewModel @Inject constructor(
_showProblems.value = true
return
}
val target = _editTarget.value
if (target != null && form == target.original) {
// A pristine form saves as a no-op instead of a write.
_saveState.value = SaveUiState.Saved
return
}
if (target != null && target.original.rrule != null) {
_saveState.value = SaveUiState.AwaitingScope
return
}
performSave(form, RecurringWriteScope.AllEvents)
}
/** Finish a save parked in [SaveUiState.AwaitingScope]. */
fun saveWithScope(scope: RecurringWriteScope) {
val current = state.value ?: return
if (current.saveState != SaveUiState.AwaitingScope) return
performSave(current.form, scope)
}
/** Finish a save parked in [SaveUiState.AwaitingConflict], overwriting. */
fun saveOverwriting() {
val current = state.value ?: return
val parked = current.saveState as? SaveUiState.AwaitingConflict ?: return
performSave(current.form, parked.scope, ignoreConflict = true)
}
private fun performSave(
form: EventForm,
scope: RecurringWriteScope,
ignoreConflict: Boolean = false,
) {
val target = _editTarget.value
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
// No locking (plan 03, decision 5): right before writing, re-read
// the event and compare against what the form loaded. An external
// change parks the save in a conflict dialog instead of silently
// clobbering the edited fields.
if (target != null && !ignoreConflict) {
val fresh = try {
repository.eventDetail(target.eventId)
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_saveState.value = SaveUiState.Gone
return@launch
} catch (e: Exception) {
// Can't verify — proceed; a real problem fails the write itself.
null
}
if (fresh != null && fresh != target.snapshot) {
_saveState.value = SaveUiState.AwaitingConflict(scope)
return@launch
}
}
_saveState.value = try {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
if (target == null) {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
} else {
when (scope) {
RecurringWriteScope.ThisEvent ->
repository.updateOccurrence(target.eventId, target.beginMillis, form)
RecurringWriteScope.ThisAndFollowing ->
repository.updateEventFromOccurrence(
eventId = target.eventId,
beginMillis = target.beginMillis,
original = target.original,
updated = form,
)
RecurringWriteScope.AllEvents ->
repository.updateEvent(target.eventId, target.original, form)
}
}
SaveUiState.Saved
} catch (e: CancellationException) {
throw e

View File

@@ -0,0 +1,163 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.navigationBarsPadding
import androidx.compose.foundation.layout.offset
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.filled.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** MD3 8dp spacing scale shared by the onboarding screens. */
internal object OnboardingSpace {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
/**
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
* centred hero + body with the call(s) to action pinned to the bottom (clear
* of the navigation bar). The content slot is centred horizontally; benefit
* rows fill the width so their own content left-aligns.
*/
@Composable
internal fun OnboardingScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = OnboardingSpace.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(OnboardingSpace.xl))
hero()
Spacer(Modifier.height(OnboardingSpace.lg))
body()
Spacer(Modifier.height(OnboardingSpace.md))
}
}
}
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
@Composable
internal fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.fillMaxSize(),
)
}
if (denied) {
// A small lock badge sits over the corner to signal "blocked".
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp)
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(OnboardingSpace.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -6,25 +6,14 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.navigationBarsPadding
import androidx.compose.foundation.layout.offset
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.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth
@@ -34,7 +23,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -42,18 +30,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R
private val CALENDAR_PERMISSIONS = arrayOf(
@@ -61,15 +44,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.WRITE_CALENDAR,
)
// MD3 8dp spacing scale, scoped to this screen.
private object Space {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
@Composable
fun PermissionScreen(
onGranted: () -> Unit,
@@ -118,7 +92,7 @@ private fun RationaleContent(
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
PermissionScaffold(
OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = false) },
actions = {
@@ -131,7 +105,7 @@ private fun RationaleContent(
text = stringResource(R.string.permission_request_button),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(Space.xs))
Spacer(Modifier.width(OnboardingSpace.xs))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
@@ -147,7 +121,7 @@ private fun RationaleContent(
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(Space.xs))
Spacer(Modifier.height(OnboardingSpace.xs))
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
@@ -161,20 +135,20 @@ private fun RationaleContent(
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(Space.xl))
Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Filled.Lock,
title = stringResource(R.string.permission_benefit_private_title),
body = stringResource(R.string.permission_benefit_private_body),
)
Spacer(Modifier.height(Space.sm))
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.CalendarMonth,
title = stringResource(R.string.permission_benefit_sync_title),
body = stringResource(R.string.permission_benefit_sync_body),
)
Spacer(Modifier.height(Space.sm))
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.permission_benefit_privacy_title),
@@ -189,7 +163,7 @@ private fun DeniedContent(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
PermissionScaffold(
OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = true) },
actions = {
@@ -231,122 +205,6 @@ private fun DeniedContent(
}
}
/**
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
* action pinned to the bottom (clear of the navigation bar). The content slot is
* centred horizontally; benefit rows fill the width so their own content
* left-aligns.
*/
@Composable
private fun PermissionScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = Space.md, vertical = Space.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = Space.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(Space.xl))
hero()
Spacer(Modifier.height(Space.lg))
body()
Spacer(Modifier.height(Space.md))
}
}
}
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
@Composable
private fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.fillMaxSize(),
)
}
if (denied) {
// A small lock badge sits over the corner to signal "blocked".
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp)
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(Space.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun PrivacyFootnote() {
Row(

View File

@@ -0,0 +1,138 @@
package de.jeanlucmakiola.calendula.ui.permission
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.R
/**
* One-time onboarding step after the calendar grant (v1.4): explains that
* Calendula delivers reminder notifications itself, warns about duplicates
* when a second calendar app has notifications on, and requests
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
*
* Reminders default ON: [onFinished] gets true from the primary action even
* if the system dialog is declined — the OS permission is the real gate, and
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
*/
@Composable
fun ReminderOnboardingScreen(
onFinished: (remindersEnabled: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { onFinished(true) }
OnboardingScaffold(
modifier = modifier,
hero = { BellHero() },
actions = {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onFinished(true)
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.reminder_onboarding_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = { onFinished(false) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.reminder_onboarding_skip_button))
}
},
) {
Text(
text = stringResource(R.string.app_name).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(OnboardingSpace.xs))
Text(
text = stringResource(R.string.reminder_onboarding_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.reminder_onboarding_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Filled.NotificationsActive,
title = stringResource(R.string.reminder_benefit_delivery_title),
body = stringResource(R.string.reminder_benefit_delivery_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.ContentCopy,
title = stringResource(R.string.reminder_benefit_duplicates_title),
body = stringResource(R.string.reminder_benefit_duplicates_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.Tune,
title = stringResource(R.string.reminder_benefit_reversible_title),
body = stringResource(R.string.reminder_benefit_reversible_body),
)
}
}
/** A bell in the brand squircle — same silhouette as the permission hero. */
@Composable
private fun BellHero() {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.NotificationsActive,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
* grant. [onboardingDone] is null until DataStore's first emission so the
* step neither flashes for users who completed it nor gets skipped.
*/
@HiltViewModel
class ReminderOnboardingViewModel @Inject constructor(
private val prefs: SettingsPrefs,
) : ViewModel() {
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
.map { done -> done as Boolean? }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Close the step, recording whether reminder notifications stay on. */
fun finish(remindersEnabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
}
}
}

View File

@@ -1,7 +1,12 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -42,6 +47,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@@ -128,6 +134,13 @@ fun SettingsScreen(
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_notifications))
RemindersRow(
checked = state.remindersEnabled,
onCheckedChange = viewModel::setRemindersEnabled,
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_language))
LanguageRow()
@@ -249,6 +262,55 @@ private fun DynamicColorRow(
}
}
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
* the pref is set either way; the OS permission is the real gate.
*/
@Composable
private fun RemindersRow(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_reminders),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_reminders_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(16.dp))
Switch(
checked = checked,
onCheckedChange = { enabled ->
onCheckedChange(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
if (needsPermission) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
)
}
}
@Composable
private fun AboutSection() {
val context = LocalContext.current
@@ -340,6 +402,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
}

View File

@@ -18,4 +18,6 @@ data class SettingsUiState(
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true,
)

View File

@@ -28,13 +28,15 @@ class SettingsViewModel @Inject constructor(
prefs.dynamicColor,
prefs.weekStart,
prefs.defaultFormFields,
) { theme, dynamic, weekStart, formFields ->
prefs.remindersEnabled,
) { theme, dynamic, weekStart, formFields, reminders ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
remindersEnabled = reminders,
)
}.stateIn(
scope = viewModelScope,
@@ -57,4 +59,8 @@ class SettingsViewModel @Inject constructor(
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
</vector>

View File

@@ -45,12 +45,15 @@
<!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
<string name="event_detail_delete">Löschen</string>
<string name="event_delete_title">Termin löschen?</string>
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
<string name="event_delete_option_occurrence">Nur dieser Termin</string>
<string name="event_delete_option_following">Dieser und alle folgenden Termine</string>
<string name="event_delete_option_series">Alle Termine der Serie</string>
<string name="event_edit_recurring_title">Wiederkehrenden Termin bearbeiten</string>
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
<string name="dialog_cancel">Abbrechen</string>
@@ -78,6 +81,31 @@
<string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
<string name="event_edit_conflict_overwrite">Meine Änderungen speichern</string>
<string name="event_edit_conflict_overwrite_hint">Nur von dir bearbeitete Felder überschreiben die externe Änderung</string>
<string name="event_edit_conflict_discard">Meine Änderungen verwerfen</string>
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
<string name="event_edit_recurrence_every">Alle</string>
<string name="recurrence_unit_days">Tage</string>
<string name="recurrence_unit_weeks">Wochen</string>
<string name="recurrence_unit_months">Monate</string>
<string name="recurrence_unit_years">Jahre</string>
<string name="event_edit_recurrence_ends">Endet</string>
<string name="event_edit_recurrence_end_never">Nie</string>
<string name="event_edit_recurrence_end_until">An einem Datum</string>
<string name="event_edit_recurrence_end_count">Nach einer Anzahl</string>
<string name="event_edit_recurrence_times">Mal</string>
<string name="event_edit_error_recurrence_ends_before_start">Wiederholung endet vor dem Beginn</string>
<string name="event_availability_busy">Beschäftigt</string>
<string name="event_access_default">Standard</string>
<string name="event_access_public">Öffentlich</string>
@@ -141,6 +169,20 @@
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
<string name="reminder_channel_name">Termin-Erinnerungen</string>
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
<string name="reminder_onboarding_title">Keinen Termin mehr verpassen</string>
<string name="reminder_onboarding_body">Android zeigt Termin-Erinnerungen nicht von selbst — das muss eine Kalender-App tun. Überlass Calendula den Job.</string>
<string name="reminder_benefit_delivery_title">Erinnerungen, zugestellt</string>
<string name="reminder_benefit_delivery_body">Jede Erinnerung an deinen Terminen kommt pünktlich als Benachrichtigung an.</string>
<string name="reminder_benefit_duplicates_title">Noch eine zweite Kalender-App?</string>
<string name="reminder_benefit_duplicates_body">Wenn eine andere App ebenfalls Erinnerungen zeigt, siehst du sie doppelt — schalte sie dort oder hier ab.</string>
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
<string name="reminder_onboarding_skip_button">Später</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
<string name="view_week">Woche</string>
@@ -165,6 +207,9 @@
<string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_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_language">Sprache</string>
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>

View File

@@ -46,12 +46,15 @@
<!-- Event detail screen (S4) -->
<string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string>
<string name="event_delete_title">Delete event?</string>
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
<string name="event_delete_recurring_title">Delete recurring event</string>
<string name="event_delete_option_occurrence">Only this event</string>
<string name="event_delete_option_following">This and all following events</string>
<string name="event_delete_option_series">All events in the series</string>
<string name="event_edit_recurring_title">Edit recurring event</string>
<string name="event_delete_failed">Couldn\'t delete the event</string>
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
<string name="dialog_cancel">Cancel</string>
@@ -79,6 +82,31 @@
<string name="reminder_unit_weeks">weeks</string>
<string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string>
<!-- Event form — save conflict (v2.0) -->
<string name="event_edit_conflict_title">Event changed elsewhere</string>
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
<string name="event_edit_conflict_overwrite">Save my changes</string>
<string name="event_edit_conflict_overwrite_hint">Only fields you edited overwrite the outside change</string>
<string name="event_edit_conflict_discard">Discard my changes</string>
<string name="event_edit_conflict_discard_hint">The event stays as it is now</string>
<string name="event_edit_gone_title">Event deleted</string>
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
<!-- Event form — recurrence picker (v1.3) -->
<string name="event_edit_recurrence_none">Does not repeat</string>
<string name="event_edit_recurrence_custom">Custom</string>
<string name="event_edit_recurrence_every">Every</string>
<string name="recurrence_unit_days">days</string>
<string name="recurrence_unit_weeks">weeks</string>
<string name="recurrence_unit_months">months</string>
<string name="recurrence_unit_years">years</string>
<string name="event_edit_recurrence_ends">Ends</string>
<string name="event_edit_recurrence_end_never">Never</string>
<string name="event_edit_recurrence_end_until">On a date</string>
<string name="event_edit_recurrence_end_count">After a number of times</string>
<string name="event_edit_recurrence_times">times</string>
<string name="event_edit_error_recurrence_ends_before_start">Repeats end before the event starts</string>
<string name="event_availability_busy">Busy</string>
<string name="event_access_default">Default</string>
<string name="event_access_public">Public</string>
@@ -142,6 +170,20 @@
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
<!-- Reminder notifications (v1.4) -->
<string name="reminder_channel_name">Event reminders</string>
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
<string name="reminder_onboarding_title">Never miss an event</string>
<string name="reminder_onboarding_body">Android doesn\'t show event reminders by itself — a calendar app has to. Let Calendula take that job.</string>
<string name="reminder_benefit_delivery_title">Reminders, delivered</string>
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
<string name="reminder_benefit_reversible_title">Change it anytime</string>
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
<string name="reminder_onboarding_skip_button">Not now</string>
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
<string name="view_week">Week</string>
@@ -166,6 +208,9 @@
<string name="settings_week_start_sunday">Sunday</string>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_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_language">Language</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>

View File

@@ -198,6 +198,43 @@ class CalendarRepositoryImplTest {
}
}
@Test
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
)
val updated = original.copy(title = "Daily")
repo.updateEvent(eventId = 42L, original = original, updated = updated)
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
}
@Test
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("update event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
try {
repo.updateEvent(eventId = 42L, original = form, updated = form.copy(title = "X"))
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("42")
}
}
@Test
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
@@ -220,6 +257,61 @@ class CalendarRepositoryImplTest {
assertThat(fake.deletedEventIds).isEmpty()
}
@Test
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
assertThat(fake.deletedFromOccurrences).containsExactly(42L to 1_000L)
assertThat(fake.deletedEventIds).isEmpty()
assertThat(fake.deletedOccurrences).isEmpty()
}
@Test
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Moved",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
assertThat(id).isEqualTo(88L)
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Weekly",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
rrule = "FREQ=WEEKLY",
)
val updated = original.copy(title = "Weekly, renamed")
val id = repo.updateEventFromOccurrence(
eventId = 42L,
beginMillis = 1_000L,
original = original,
updated = updated,
)
assertThat(id).isEqualTo(99L)
assertThat(fake.updatedFromOccurrences).containsExactly(Triple(42L, 1_000L, updated))
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {

View File

@@ -88,6 +88,12 @@ class EventDetailMapperTest {
assertThat(detail.attendees).isEmpty()
}
@Test
fun `missing title stays raw so the edit form does not inherit a placeholder`() {
assertThat(detailReader(title = null).toDetail()!!.instance.title).isEmpty()
assertThat(detailReader(title = "").toDetail()!!.instance.title).isEmpty()
}
@Test
fun `event color falls back to calendar color when null`() {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())

View File

@@ -71,4 +71,151 @@ class EventWriteMapperTest {
// 11th, 12th, 13th inclusive = 3 days.
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
}
@Test
fun `truncation cutoff is the end of the previous local day`() {
// June 9 2026 15:30Z == 17:30 in Berlin. The cutoff must be
// June 8 23:59:59 Berlin == June 8 21:59:59Z.
assertThat(previousLocalDayEndUtcMillis(1_781_019_000_000L, berlin))
.isEqualTo(1_780_955_999_000L)
// All-day series live in UTC: the cutoff for a June 9 UTC-midnight
// occurrence is June 8 23:59:59Z.
assertThat(previousLocalDayEndUtcMillis(1_780_963_200_000L, ZoneId.of("UTC")))
.isEqualTo(1_780_963_199_000L)
}
@Test
fun `duration renders seconds for timed and days for all-day events`() {
assertThat(form().toWriteTimes(berlin).toRfc2445Duration(isAllDay = false))
.isEqualTo("P5400S")
assertThat(form(isAllDay = true).toWriteTimes(berlin).toRfc2445Duration(isAllDay = true))
.isEqualTo("P1D")
}
// --- buildEventUpdateValues (dirty-checked partial update) ---
private val seriesStart = 1_700_000_000_000L
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
buildEventUpdateValues(original, updated, seriesStart, berlin)
@Test
fun `pristine form produces no values`() {
val original = form()
assertThat(update(original, original.copy())).isEmpty()
}
@Test
fun `text-only edit writes just the changed columns`() {
val original = form()
val values = update(original, original.copy(title = "New", description = "Body"))
assertThat(values).containsExactly(
CalendarContract.Events.TITLE, "New",
CalendarContract.Events.DESCRIPTION, "Body",
)
}
@Test
fun `clearing location writes an explicit null`() {
val original = form().copy(location = "Berlin")
val values = update(original, original.copy(location = " "))
assertThat(values).containsExactly(CalendarContract.Events.EVENT_LOCATION, null)
}
@Test
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
val original = form()
val updated = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
)
val values = update(original, updated)
// 2026-06-11 11:00 CEST == 09:00Z.
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(1_781_164_800_000L + 3_600_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 2L * 3_600_000L)
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
@Test
fun `time edit on a recurring event moves the series start by the same delta`() {
val original = form().copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(
// Pushed one hour later than the displayed occurrence.
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
}
@Test
fun `adding a recurrence keeps the times and writes rule plus duration`() {
val original = form()
val values = update(original, original.copy(rrule = "FREQ=DAILY;COUNT=5"))
// The event was one-off, so the row's DTSTART is the occurrence start
// and a zero delta keeps it in place.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
}
@Test
fun `removing the recurrence writes absolute occurrence times and clears the rule`() {
val original = form().copy(rrule = "FREQ=WEEKLY")
val values = update(original, original.copy(rrule = null))
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 5_400_000L)
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
}
@Test
fun `reminder-only changes touch no event columns`() {
val original = form()
assertThat(update(original, original.copy(reminders = listOf(10)))).isEmpty()
}
// --- buildOccurrenceExceptionValues ("edit only this event") ---
@Test
fun `occurrence exception carries absolute times and the original instance`() {
val values = buildOccurrenceExceptionValues(
form = form().copy(title = "Moved", location = "Berlin"),
originalInstanceMillis = 1_700_000_000_000L,
zone = berlin,
)
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_700_000_000_000L)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 5_400_000L)
// A single occurrence never carries its own rule.
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
}
@Test
fun `occurrence exception clears empty optionals explicitly`() {
// The provider clones the parent row, so a blank field must be an
// explicit NULL or the parent's value would survive.
val values = buildOccurrenceExceptionValues(
form = form(),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
}
}

View File

@@ -20,8 +20,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var nextInsertId: Long = 100L
val insertedForms = mutableListOf<EventForm>()
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
private val listeners = mutableListOf<() -> Unit>()
@@ -36,6 +40,33 @@ internal class FakeCalendarDataSource : CalendarDataSource {
return nextInsertId
}
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
writeError?.let { throw it }
updatedEvents += Triple(eventId, original, updated)
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
writeError?.let { throw it }
updatedOccurrences += Triple(eventId, beginMillis, form)
return nextInsertId
}
override fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long {
writeError?.let { throw it }
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
return nextInsertId
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
writeError?.let { throw it }
deletedFromOccurrences += eventId to beginMillis
}
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId

View File

@@ -100,6 +100,27 @@ class SettingsPrefsTest {
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
}
@Test
fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.remindersEnabled.first()).isTrue()
assertThat(prefs.reminderOnboardingDone.first()).isFalse()
}
@Test
fun `reminders toggle round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setRemindersEnabled(false)
assertThat(prefs.remindersEnabled.first()).isFalse()
}
@Test
fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setReminderOnboardingDone()
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)

View File

@@ -0,0 +1,85 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Locale
class ReminderTimeTextTest {
private val berlin = ZoneId.of("Europe/Berlin")
private fun millisAt(dateTime: LocalDateTime, zone: ZoneId): Long =
dateTime.atZone(zone).toInstant().toEpochMilli()
private fun utcMidnight(date: LocalDate): Long =
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
@Test
fun `timed event on one day shows just the time range`() {
val text = reminderTimeText(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("09:30 10:00")
}
@Test
fun `timed event crossing midnight includes both dates`() {
val text = reminderTimeText(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).contains("11.06.2026")
assertThat(text).contains("12.06.2026")
assertThat(text).contains("23:30")
}
@Test
fun `all-day single day shows one date, read in UTC`() {
val text = reminderTimeText(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day.
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("11.06.2026")
}
@Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
val text = reminderTimeText(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("11.06.2026 12.06.2026")
}
@Test
fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11))
val text = reminderTimeText(
beginMillis = day,
endMillis = day,
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("11.06.2026")
}
}

View File

@@ -4,7 +4,9 @@ import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class EventFormTest {
@@ -69,4 +71,170 @@ class EventFormTest {
EventFormProblem.EndBeforeStart,
)
}
@Test
fun `recurrence until before the first day is a problem`() {
// Days before the start, so it parses to an earlier date in any zone.
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
assertThat(bad.problems())
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
@Test
fun `recurrence until on or after the first day is fine`() {
// Date-only UNTIL parses zone-independently.
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
}
@Test
fun `complex rrules are not validated against the start`() {
// The picker can't have produced this ("second Monday" ordinal BYDAY);
// it is preserved verbatim and never flagged.
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
}
@Test
fun `weekly byday rules are validated against the start`() {
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
assertThat(bad.problems())
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
private val berlin = TimeZone.of("Europe/Berlin")
private fun detail(
isAllDay: Boolean = false,
title: String = "Stand-up",
location: String? = "Berlin",
description: String? = "Body",
rrule: String? = null,
reminders: List<Reminder> = emptyList(),
availability: Availability = Availability.Busy,
accessLevel: AccessLevel = AccessLevel.Default,
rowStart: Long = 0L,
rowEnd: Long = 0L,
attendees: List<Attendee> = emptyList(),
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 7L,
title = title,
start = Instant.fromEpochMilliseconds(rowStart),
end = Instant.fromEpochMilliseconds(rowEnd),
isAllDay = isAllDay,
color = 0,
location = location,
),
description = description,
organizer = null,
attendees = attendees,
rrule = rrule,
reminders = reminders,
availability = availability,
accessLevel = accessLevel,
)
@Test
fun `toEditForm prefills a timed event from the occurrence times`() {
// 2026-06-11 is CEST (UTC+2): 08:00Z == 10:00 local.
val prefilled = detail().toEditForm(
beginMillis = 1_781_164_800_000L,
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
zone = berlin,
)
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.isAllDay).isFalse()
assertThat(prefilled.calendarId).isEqualTo(7L)
assertThat(prefilled.title).isEqualTo("Stand-up")
assertThat(prefilled.location).isEqualTo("Berlin")
assertThat(prefilled.description).isEqualTo("Body")
}
@Test
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
val prefilled = detail(isAllDay = true).toEditForm(
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
endMillis = LocalDate(2026, 6, 14).toEpochDays() * 86_400_000L,
zone = berlin,
)
assertThat(prefilled.start.date).isEqualTo(LocalDate(2026, 6, 11))
assertThat(prefilled.end.date).isEqualTo(LocalDate(2026, 6, 13))
assertThat(prefilled.isAllDay).isTrue()
}
@Test
fun `toEditForm sorts and dedupes reminder minutes and strips an RRULE prefix`() {
val prefilled = detail(
rrule = "RRULE:FREQ=WEEKLY",
reminders = listOf(
Reminder(30, ReminderMethod.Email),
Reminder(10, ReminderMethod.Alert),
Reminder(30, ReminderMethod.Alert),
),
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.reminders).containsExactly(10, 30).inOrder()
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `snapshots of an unchanged event are equal`() {
val a = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val b = detail().toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(b).isEqualTo(a)
}
@Test
fun `an external field change makes snapshots differ`() {
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(title = "Stand-up (moved)").toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `an external time move is caught by the row times the form cannot see`() {
// Both snapshots are taken for the same tapped occurrence, so the
// *forms* derive identical times — only rowStart/rowEnd betray the move.
val loaded = detail(rrule = "FREQ=WEEKLY", rowStart = 0L)
.toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(rrule = "FREQ=WEEKLY", rowStart = 86_400_000L)
.toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh.form).isEqualTo(loaded.form)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `changes the form cannot write do not fake a conflict`() {
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
).toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isEqualTo(loaded)
}
@Test
fun `populatedFields reports exactly the sections holding values`() {
val empty = form().copy(location = "", description = "")
assertThat(empty.populatedFields()).isEmpty()
val full = form().copy(
location = "Berlin",
description = "Body",
reminders = listOf(10),
rrule = "FREQ=DAILY",
availability = Availability.Free,
accessLevel = AccessLevel.Private,
)
assertThat(full.populatedFields()).containsExactly(
EventFormField.Location,
EventFormField.Description,
EventFormField.Reminders,
EventFormField.Recurrence,
EventFormField.Availability,
EventFormField.Visibility,
)
}
}

View File

@@ -0,0 +1,164 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
class RecurrenceTest {
private val utc = TimeZone.UTC
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `plain frequency parses with defaults`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly))
assertThat(parseSimpleRecurrence("FREQ=DAILY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily))
}
@Test
fun `leading RRULE prefix and WKST are tolerated`() {
assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly))
}
@Test
fun `interval parses`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2))
}
@Test
fun `until parses date-only and UTC datetime forms`() {
val expected = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc))
.isEqualTo(expected)
}
@Test
fun `until datetime converts from UTC into the given zone before taking the date`() {
// 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there.
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin))
.isEqualTo(
SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))),
)
}
@Test
fun `count parses`() {
assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5)))
}
@Test
fun `weekly byday parses as weekday picks`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR"))
.isEqualTo(
SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
),
)
}
@Test
fun `rules beyond the simple shape are rejected`() {
// Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules.
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull()
assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull()
assertThat(parseSimpleRecurrence("")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull()
}
@Test
fun `toRRule renders the minimal form`() {
assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY")
assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule())
.isEqualTo("FREQ=DAILY;INTERVAL=3")
assertThat(
SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(),
).isEqualTo("FREQ=MONTHLY;COUNT=12")
}
@Test
fun `toRRule renders weekdays in ISO order regardless of set order`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR")
}
@Test
fun `toRRule ignores weekday picks on non-weekly frequencies`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Monthly,
byDays = setOf(DayOfWeek.MONDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=MONTHLY")
}
@Test
fun `toRRule writes until as the end of the chosen day in the given zone`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(rule.toRRule(utc))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z")
// 23:59:59 Berlin (CEST, +2) == 21:59:59Z.
assertThat(rule.toRRule(berlin))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z")
}
// 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence.
private val cutoffMillis = 1_781_913_540_000L
@Test
fun `truncation replaces count and keeps every other part`() {
assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z")
}
@Test
fun `truncation replaces an existing until`() {
assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis))
.isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z")
}
@Test
fun `truncation works on rules the simple picker cannot express`() {
assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis))
.isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z")
}
@Test
fun `parse and render round-trip`() {
val rules = listOf(
"FREQ=DAILY",
"FREQ=WEEKLY;INTERVAL=2",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z",
"FREQ=MONTHLY;COUNT=6",
"FREQ=YEARLY;UNTIL=20301231T235959Z",
)
rules.forEach { rule ->
assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule)
}
// Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET.
val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z"
assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin))
.isEqualTo(berlinRule)
}
}

View File

@@ -47,8 +47,8 @@ Domain bleibt pure Kotlin.
|---|---|---|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | offen |
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | ausgeliefert (v1.3.0, 2026-06-11) |
| v2.0 | Konflikt-Dialog, Polish-Pass (Store-Copy, Screenshots), Release | ausgeliefert (v2.0.0, 2026-06-11) |
## v1.1 — Write-Fundament + Delete
@@ -95,15 +95,110 @@ Domain bleibt pure Kotlin.
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
## v1.3 — Edit (Skizze)
## v1.3 — Edit
- Formular lädt `EventDetail`, Dirty-Check, `update` auf Events-Row
- Reminder hinzufügen/entfernen (`Reminders`-Insert/Delete)
- Einfacher Recurrence-Picker (FREQ + INTERVAL + UNTIL/COUNT)
**Domain:**
- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln
(ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten,
solange der Picker sie nicht ersetzt
- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches
BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit
`parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet)
- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day-
Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)
- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße
null Vorkommen — Event würde unsichtbar)
## v2.0 — Abschluss (Skizze)
**Data layer:**
- [x] `buildEventUpdateValues(original, updated, seriesDtStart, zone)`
Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit:
einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend →
Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTEND
- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row-
Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)
- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)
- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io`
- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr —
der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
- Occurrence-Edit (Exception mit geänderten Werten)
- Konflikt-Dialog beim Speichern
- Changelog, F-Droid-Metadaten, Release-Tag
**UI:**
- [x] `EventEditViewModel.openForEdit` (lädt Detail, merkt Original für
Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit
Werten werden unabhängig vom Settings-Default eingeblendet
- [x] `EventEditScreen`: `editKey`-Parameter, Kalender im Edit-Modus fixiert,
Repeat-Karte + `RecurrencePickerDialog` (Presets per Tap, Custom-Schritt
mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart
nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl,
OptionCard-Stil)
- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular)
- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller
WRITE-Request wie Delete); Save schließt Formular **und** Detail (die
getappte Occurrence existiert danach evtl. nicht mehr)
- [x] **Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback):** Die
Scope-Frage kommt **beim Speichern** (Google-Modell, Review-Feedback):
ein dirty wiederkehrender Termin parkt in `SaveUiState.AwaitingScope`,
der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden /
Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser"
(eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt
eine Modified-Occurrence-Exception (`CONTENT_EXCEPTION_URI`, alle
Formularwerte, leere Optionals als explizite NULLs weil der Provider
die Serien-Row klont), Reminder werden gegen die tatsächlichen
Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split:
neues Event mit den Formularwerten (insert zuerst — schlägt es fehl,
bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt;
ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes
COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP)
- [x] **Delete dreistufig (Review-Feedback):** "Nur dieser Termin" /
"Dieser und alle folgenden" (RRULE-Truncation via `rruleTruncatedAt`)
/ "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie
löschen
- [x] **Split-Duplikat-Bugfix (On-Device-Review):** Nach dem Serien-Split
blieb die getappte Occurrence doppelt sichtbar. Root cause (per
adb-Probe verifiziert): der Provider regeneriert die Instances eines
Events nur aus den **Values des Updates selbst** — ein RRULE-only-
Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART)
erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken
deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/
EVENT_TIMEZONE) zusammen (`truncateSeries`), wie AOSPs
EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff =
Ende des Vortags in der Event-Zeitzone (`previousLocalDayEndUtcMillis`)
statt Occurrence1s, und der Recurrence-Picker rendert UNTIL als
lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z`
(sonst kann bei UTC+x ein Extra-Tag hineinrutschen)
- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern
- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default)
- [x] Strings DE+EN
**Tests:**
- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln)
- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung
- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only,
Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)
- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade
- [x] `EventDetailMapperTest`: roher Titel
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
## v2.0 — Abschluss (Scope-Recut 2026-06-11, nach v1.4)
- ~~Quick-Add-Sheet (Titel + Zeit, Rest Defaults)~~ — **gestrichen**: das
Formular öffnet bereits vorbefüllt (sichtbarer Tag, zuletzt benutzter
Kalender, optionale Felder versteckt); der Sheet spart nur einen
Screen-Übergang und kostet eine zweite Create-Surface. Nur bei
Praxis-Feedback wieder aufnehmen
- ~~Occurrence-Edit (Exception mit geänderten Werten)~~ — schon in v1.3
ausgeliefert (vorgezogen)
- [x] Konflikt-Dialog beim Speichern (Leitentscheidung 5): `EditSnapshot`
(Formular + rohe Row-Zeiten) wird beim Laden gemerkt und vor dem
Schreiben gegen einen frischen Read verglichen; Abweichung parkt den
Save in `AwaitingConflict` (Überschreiben/Verwerfen/Abbrechen,
OptionCard-Stil), gelöschtes Event → `Gone`-Dialog. "Überschreiben"
schreibt weiterhin nur dirty Felder
- Kalender-Wechsel beim Bearbeiten → v3-Backlog (copy+delete-Modell)
- [x] Polish: F-Droid-Description + README auf Write-Support + Reminder
aktualisiert (DE+EN)
- [x] F-Droid-Screenshots (de-DE + en-US, je 6: Woche/Monat/Tag/Detail/
Formular/Onboarding) — mit Demo-Kalendern auf dem Gerät aufgenommen
- [x] Changelog, Release-Tag v2.0.0 (ausgeliefert 2026-06-11 — Milestone 2
damit abgeschlossen)

View File

@@ -0,0 +1,119 @@
# Calendula - Plan 04: Reminder Notifications (v1.4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
Der Provider plant die Alarme und broadcastet
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
`METHOD_ALERT`.
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
"fällig & noch SCHEDULED".
3. Etar postet aus dem Zustand `SCHEDULED FIRED` und verwaltet Dismiss über
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
`setOnlyAlertOnce`).
**Leitentscheidungen:**
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
v1.0v1.3-Upgrader, die das Feature so entdecken.
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
wenn sie fehlt.
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
der Detail-Overlay selbst).
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
Vollbild-Alarm.
---
## Tasks
**Manifest / Resourcen:**
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
host=com.android.calendar`; `MainActivity``launchMode="singleTop"`
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
**Prefs:**
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
**Data layer (`data/reminders/`):**
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
title, location, isAllDay
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
`STATE_SCHEDULED AND ALARM_TIME <= now`;
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
`SecurityException` → Log (Write-Recht optional)
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
Category EVENT), Content-PendingIntent auf `MainActivity` mit
eventId/begin/end
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
**Receiver:**
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
Notifications systemseitig geblockt; sonst posten → `markFired`
**UI:**
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
setzen `reminder_onboarding_done`
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
onNewIntent) in Compose-State
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
Permission-Request beim Einschalten) + Duplikat-Hinweistext
**Abschluss:**
- [x] `./gradlew lint test assembleDebug` grün
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
On-Device-Review

View File

@@ -1,12 +1,17 @@
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie liest
direkt aus dem System-Kalender-Provider — jede Quelle, die mit deinem Gerät
synchronisiert ist (Nextcloud über DAVx5, Google, lokal, WebCal-Subscriptions)
erscheint automatisch.
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie
arbeitet direkt auf dem System-Kalender-Provider — jede Quelle, die mit
deinem Gerät synchronisiert ist (Nextcloud über DAVx5, Google, lokal,
WebCal-Subscriptions), erscheint automatisch, und deine Änderungen
synchronisieren auf demselben Weg zurück.
Termine erstellen, bearbeiten und löschen — auch wiederkehrende, mit
wählbarer Reichweite (nur dieser Termin / dieser und alle folgenden / ganze
Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
Termin.
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
V1 ist read-only. Erstellen, Bearbeiten und Löschen von Events kommt mit V2.
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff — deine
Daten bleiben auf dem Gerät.
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff —
deine Daten bleiben auf dem Gerät.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -1,11 +1,15 @@
Calendula is a modern, open-source calendar app for Android. It reads from
the system calendar provider, so any source synced to your device — Nextcloud
via DAVx5, Google, local, WebCal subscriptions — shows up automatically.
Calendula is a modern, open-source calendar app for Android. It works
directly on the system calendar provider, so any source synced to your
device — Nextcloud via DAVx5, Google, local, WebCal subscriptions — shows up
automatically, and changes you make sync back the same way.
The differentiator is the design: real Material 3 Expressive throughout, with
dynamic color, expressive motion, and expressive shapes.
Create, edit and delete events, including recurring events with scoped
changes (only this event / this and all following / the whole series) and a
simple repeat picker. Calendula also delivers your event reminders as
notifications — tap one and you're on the event.
V1 is read-only. Event creation, editing, and deletion are planned for V2.
The differentiator is the design: real Material 3 Expressive throughout,
with dynamic color, expressive motion, and expressive shapes.
Privacy: zero telemetry, no analytics, no network access — your data never
leaves the device.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB