Changelog#
All notable changes to Kalendar will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
This page mirrors CHANGELOG.md at the repository root, which is the canonical copy.
[2.0.0]#
2.0.0 is a ground-up redesign. There is no compatibility guarantee with 1.x, and none with any 2.0.0 release candidate. Every public type has moved, most have been rewritten, and the 1.x entry point no longer exists. This is not a version you upgrade into by bumping a number — read the migration table below, then Migration from 1.x.
Two things define the release:
kalendar-foundationis a public, documented headless calendar engine. The date arithmetic, paging arithmetic, grid layout, overlap packing, drag maths and selection reduction that were sealed inside one flat internal package are now a supported API — enough to build a calendar Kalendar did not design, without depending on Kalendar's UI. Its public surface went from 42 lines of API dump to 334. See The headless engine.- The package
com.himanshoe.kalendar.viewno longer exists. "View" is a Swing-and-Android-View word in a library whose entire selling point is Compose, and one flat 27-file package gave an IDE user nothing to navigate. The nine calendar composables sit at the package root, withstate,theme,slotandcomponentbeside them.
Artifact coordinates at the time of writing: com.himanshoe:kalendar:2.0.0-RC3,
com.himanshoe:kalendar-foundation:2.0.0, com.himanshoe:kalendar-sync:1.0.0.
Migration: every public symbol, old name to new#
Derived from the two modules' binary-compatibility dumps — kalendar/api/*/kalendar.api and
kalendar-foundation/api/*/kalendar-foundation.api — before and after the move, so nothing public is
missing from it. Fifty-one top-level entities moved. Not one was renamed and not one was removed:
every row below is a package change, so in most codebases the migration is a find-and-replace on
import lines.
Members did not move independently of their owners — KalendarViewState.visibleDate is still
visibleDate — so the table lists top-level entities only. Every member of every one of them is
documented in the API reference.
Calendar composables, their config, and their *Defaults — now at the package root, the way material3 puts Button at its root.
| 1.x / RC | 2.0.0 |
|---|---|
com.himanshoe.kalendar.view.KalendarWeek | com.himanshoe.kalendar.KalendarWeek |
com.himanshoe.kalendar.view.KalendarMonth | com.himanshoe.kalendar.KalendarMonth |
com.himanshoe.kalendar.view.KalendarYear | com.himanshoe.kalendar.KalendarYear |
com.himanshoe.kalendar.view.KalendarTimeline | com.himanshoe.kalendar.KalendarTimeline |
com.himanshoe.kalendar.view.KalendarSchedule | com.himanshoe.kalendar.KalendarSchedule |
com.himanshoe.kalendar.view.KalendarScheduleWeek | com.himanshoe.kalendar.KalendarScheduleWeek |
com.himanshoe.kalendar.view.KalendarResourceView | com.himanshoe.kalendar.KalendarResourceView |
com.himanshoe.kalendar.view.KalendarAgenda | com.himanshoe.kalendar.KalendarAgenda |
com.himanshoe.kalendar.view.KalendarDatePicker | com.himanshoe.kalendar.KalendarDatePicker |
com.himanshoe.kalendar.view.KalendarViewConfig | com.himanshoe.kalendar.KalendarViewConfig |
com.himanshoe.kalendar.view.KalendarAgendaDefaults | com.himanshoe.kalendar.KalendarAgendaDefaults |
com.himanshoe.kalendar.view.KalendarScheduleDefaults | com.himanshoe.kalendar.KalendarScheduleDefaults |
com.himanshoe.kalendar.view.KalendarResourceDefaults | com.himanshoe.kalendar.KalendarResourceDefaults |
com.himanshoe.kalendar.view.KalendarDatePickerDefaults | com.himanshoe.kalendar.KalendarDatePickerDefaults |
com.himanshoe.kalendar.view.KalendarDatePickerDayScope | com.himanshoe.kalendar.KalendarDatePickerDayScope |
com.himanshoe.kalendar.view.KalendarDatePickerActionScope | com.himanshoe.kalendar.KalendarDatePickerActionScope |
Navigation, selection and clock state → com.himanshoe.kalendar.state.
| 1.x / RC | 2.0.0 |
|---|---|
com.himanshoe.kalendar.view.KalendarViewState | com.himanshoe.kalendar.state.KalendarViewState |
com.himanshoe.kalendar.view.KalendarTimelineState | com.himanshoe.kalendar.state.KalendarTimelineState |
com.himanshoe.kalendar.view.KalendarSelectionState | com.himanshoe.kalendar.state.KalendarSelectionState |
com.himanshoe.kalendar.view.rememberKalendarWeekState | com.himanshoe.kalendar.state.rememberKalendarWeekState |
com.himanshoe.kalendar.view.rememberKalendarMonthState | com.himanshoe.kalendar.state.rememberKalendarMonthState |
com.himanshoe.kalendar.view.rememberKalendarYearState | com.himanshoe.kalendar.state.rememberKalendarYearState |
com.himanshoe.kalendar.view.rememberKalendarScheduleState | com.himanshoe.kalendar.state.rememberKalendarScheduleState |
com.himanshoe.kalendar.view.rememberKalendarTimelineState | com.himanshoe.kalendar.state.rememberKalendarTimelineState |
com.himanshoe.kalendar.view.rememberKalendarSelectionState | com.himanshoe.kalendar.state.rememberKalendarSelectionState |
com.himanshoe.kalendar.view.ProvideKalendarTimeSource | com.himanshoe.kalendar.state.ProvideKalendarTimeSource |
com.himanshoe.kalendar.view.rememberKalendarTimeSource | com.himanshoe.kalendar.state.rememberKalendarTimeSource |
com.himanshoe.kalendar.view.rememberKalendarToday | com.himanshoe.kalendar.state.rememberKalendarToday |
com.himanshoe.kalendar.view.rememberKalendarNow | com.himanshoe.kalendar.state.rememberKalendarNow |
The theme and its six token sets → com.himanshoe.kalendar.theme.
| 1.x / RC | 2.0.0 |
|---|---|
com.himanshoe.kalendar.view.KalendarTheme | com.himanshoe.kalendar.theme.KalendarTheme |
com.himanshoe.kalendar.view.KalendarThemeDefaults | com.himanshoe.kalendar.theme.KalendarThemeDefaults |
com.himanshoe.kalendar.view.KalendarThemeTokens | com.himanshoe.kalendar.theme.KalendarThemeTokens |
com.himanshoe.kalendar.view.KalendarColors | com.himanshoe.kalendar.theme.KalendarColors |
com.himanshoe.kalendar.view.KalendarTypography | com.himanshoe.kalendar.theme.KalendarTypography |
com.himanshoe.kalendar.view.KalendarShapes | com.himanshoe.kalendar.theme.KalendarShapes |
com.himanshoe.kalendar.view.KalendarDimensions | com.himanshoe.kalendar.theme.KalendarDimensions |
com.himanshoe.kalendar.view.KalendarAnimations | com.himanshoe.kalendar.theme.KalendarAnimations |
com.himanshoe.kalendar.view.KalendarStrings | com.himanshoe.kalendar.theme.KalendarStrings |
Content-slot scope types → com.himanshoe.kalendar.slot.
| 1.x / RC | 2.0.0 |
|---|---|
com.himanshoe.kalendar.view.KalendarDayScope | com.himanshoe.kalendar.slot.KalendarDayScope |
com.himanshoe.kalendar.view.KalendarDayOverflowScope | com.himanshoe.kalendar.slot.KalendarDayOverflowScope |
com.himanshoe.kalendar.view.KalendarHeaderScope | com.himanshoe.kalendar.slot.KalendarHeaderScope |
com.himanshoe.kalendar.view.KalendarScheduleEventScope | com.himanshoe.kalendar.slot.KalendarScheduleEventScope |
com.himanshoe.kalendar.view.KalendarResourceEventScope | com.himanshoe.kalendar.slot.KalendarResourceEventScope |
Default renderings and the day-cell modifier → com.himanshoe.kalendar.component.
| 1.x / RC | 2.0.0 |
|---|---|
com.himanshoe.kalendar.view.KalendarDayCellDefaults | com.himanshoe.kalendar.component.KalendarDayCellDefaults |
com.himanshoe.kalendar.view.KalendarHeaderDefaults | com.himanshoe.kalendar.component.KalendarHeaderDefaults |
com.himanshoe.kalendar.view.kalendarDaySemantics | com.himanshoe.kalendar.component.kalendarDaySemantics |
Types that turned out to be pure logic → kalendar-foundation. These moved module, not just package. kalendar re-exports the engine as an api dependency, so a single-dependency consumer needs no build change.
| 1.x / RC | 2.0.0 |
|---|---|
com.himanshoe.kalendar.view.KalendarTimeSource | com.himanshoe.kalendar.foundation.datetime.KalendarTimeSource |
com.himanshoe.kalendar.view.datesUntil | com.himanshoe.kalendar.foundation.datetime.datesUntil |
com.himanshoe.kalendar.view.KalendarSelectionMode | com.himanshoe.kalendar.foundation.selection.KalendarSelectionMode |
com.himanshoe.kalendar.view.KalendarHourWindow | com.himanshoe.kalendar.foundation.schedule.KalendarHourWindow |
com.himanshoe.kalendar.view.KalendarResource | com.himanshoe.kalendar.foundation.schedule.KalendarResource |
Newly public, with no 1.x equivalent: the whole of com.himanshoe.kalendar.foundation.datetime
(period starts, whole-period differences), .grid (weekDates, monthGridDates, monthDates,
visibleDateOnOrAfter, visibleDateStep, daysOfWeekStartingAt, ALL_DAYS_OF_WEEK), .paging
(KalendarPager, KalendarPageUnit), .format (KalendarFormatters, monthTitle), .selection
(KalendarSelection), most of .schedule (KalendarScheduleBlock, scheduleBlocks,
KalendarBlockPlacement, blockPlacement, the drag and create maths, the resource-lane functions,
and the four minute/hour constants), and byDateExpandingSpans on .event. None of these are
migrations — they were internal, and are now supported.
Breaking changes#
The calendar views are generic in their event type:
KalendarMonth<E : KalendarEvent>and so on forKalendarWeek,KalendarYear,KalendarTimeline,KalendarAgenda,KalendarSchedule,KalendarScheduleWeekandKalendarResourceView.eventsis nowList<E>rather thanKalendarEvents, and every callback and slot handsEstraight back —onDateClickreceivesList<Booking>,onEventClickreceivesBooking,resourceIdOfis given aBooking. The cast a consumer used to write to get its own type back out of a callback is gone; there is nothing left to cast.Eis inferred fromevents, so a call that already passes events needs no change at all:kotlin// before: dayEvents was List<KalendarEvent>, and reading roomId meant a cast KalendarResourceView( resources = rooms, events = bookings, resourceIdOf = { event -> (event as Booking).roomId }, onEventClick = { event -> openDetails(event as Booking) }, ) // after: E is Booking, inferred from `events` KalendarResourceView( resources = rooms, events = bookings, resourceIdOf = { booking -> booking.roomId }, onEventClick = { booking -> openDetails(booking) }, )Migration. Every row below was checked by compiling it, not by reasoning about it.
Call shape Change Passes events, callbacks take inferred parametersnone Passes events, and a callback declaresKalendarEventexplicitly (onDateClick = { _, events: List<KalendarEvent> -> … }), or passes a stored(LocalDate, List<KalendarEvent>) -> Unitor a function reference of that shapenone — a lambda parameter may be annotated with a supertype of the expected one, and a function type is contravariant in its parameters, so Estill infers to your own type and the widened annotation is acceptedPasses events, and adayContentslot is written againstKalendarDayScope<KalendarEvent>none — KalendarDayScopeis covariant, so one cell renderer still serves a calendar of any event typePasses no eventsand no event-only parameternone — an event-free overload takes over, see below Passes no eventsbut does pass an event-only parameter —onDayEventClick,onEventDrop,onEventClick,onEventTimeChange,resourceIdOf,eventContent,allDayEventContentbreaks: Cannot infer type for type parameter 'E'. Addevents = emptyList(), or name the type (KalendarSchedule<KalendarEvent>(onEventClick = …)), or drop the parameter — on an event-free calendar it could never have firedDeclares a slot type in a helper ( fun MyCell(scope: KalendarDayScope))add a type argument: KalendarDayScope<*>to read any calendar's events,KalendarDayScope<Booking>to read yoursThe fourth row is the one that costs a compile. Omitting
eventsis what selects the event-free overload, and that overload does not have these parameters at all — so passing one leaves the generic overload as the only candidate, with nothing to fixEto. It is reached by ordinary 1.x code, becauseeventsused to default toemptyList():KalendarSchedule(onEventClick = …)andKalendarResourceView(resources = rooms, resourceIdOf = …)both compiled and both now do not.events = emptyList()is the mechanical fix and infersE = KalendarEvent, matching 1.x exactly.The same rule catches the new parameters, which is worth knowing even on a fresh call site:
KalendarMonth(selectedDate = today, loadingContent = { … })does not compile on its own, becauseloadingContentexists only on the generic overload. Pass aneventLoader— which suppliesEitself — or one of the two remedies above.Every event-carrying slot scope is generic too, so a
dayContentsees the same type itsonDateClickreports rather than the erased one:KalendarDayScope<out E : KalendarEvent>,KalendarDayOverflowScope<E>,KalendarScheduleEventScope<out E>andKalendarResourceEventScope<out E> : KalendarScheduleEventScope<E>. The three covariant ones only ever produce an event, so a cell or block renderer written once against…<KalendarEvent>still serves a calendar of any event type — a slot consumes the scope, and consumers are contravariant in it.KalendarDayOverflowScopeis the exception, and is invariant, becauseonEventClick(event: E)consumes anE. Covariance there was unsound rather than merely permissive: a popover holding the scope asKalendarDayOverflowScope<KalendarEvent>could be handed aKalendarDayOverflowScope<Booking>and pass it anyKalendarEvent, which the cell's own(Booking) -> Unitreceived and threwClassCastExceptionon.KalendarDayCellDefaults.CellandKalendarMonthGridCellgained the same parameter;kalendarDaySemantics,KalendarScheduleDefaults.EventBlockandKalendarResourceDefaults.EventBlockread their interface only and take a star projection.KalendarDayCellDefaults.OverflowPopupis instead generic inE— a star projection could not callonEventClickat all.Migration. A shared
overflowPopuprenderer must become generic rather than monomorphic:overflowPopupwritten asChange A lambda at the call site ( overflowPopup = { … })none — Eis inferred from the calendarA read-only popover over KalendarDayOverflowScope<*>none — reading eventsis still allowedA stored @Composable (KalendarDayOverflowScope<KalendarEvent>) -> Unit, reused across calendarsbreaks: make it a generic composable, @Composable fun <E : KalendarEvent> MyPopup(scope: KalendarDayOverflowScope<E>), and passoverflowPopup = { MyPopup(scope = it) }This is a source-level break only; both forms erase to the same JVM signature, so it is not an ABI break.
Each view whose
eventsis optional gained an event-free overload —KalendarMonth,KalendarWeek,KalendarYear,KalendarTimeline,KalendarSchedule,KalendarScheduleWeek,KalendarResourceView.KalendarMonth(selectedDate = today)still compiles and still means what it did; it now resolves to the overload that has noeventsparameter. This is not a convenience: a Kotlin default argument is never a source of type inference, so witheventsomitted there is nothing to fixEto and a single generic function would not compile at that call site at all. Distinguishing the two by the absence ofeventsis also what keeps the overloads unambiguous — supply events and only the generic one is a candidate, supply none and only the event-free one is. Each event-free overload drops the parameters a calendar with no events could never reach (onDayEventClick,onEventDrop,onEventClick,onEventTimeChange,resourceIdOf,eventContent,allDayEventContent), and keeps everything that still draws.byDateExpandingSpans()is generic:fun <E : KalendarEvent> List<E>.byDateExpandingSpans(): Map<LocalDate, List<E>>. It is the index the views build before handing a date's events toonDateClick, so widening it there would have widened every callback downstream. Existing calls on aKalendarEventsinferE = KalendarEventand are unchanged.KalendarEventssurvives, unparameterised, and is now documented as what it always was: the widest event list there is,List<KalendarEvent>. It is not becomingKalendarEvents<E>— that would be the same length asList<E>, one more name to learn, and would force a type argument onto every place that genuinely does not care which event type it holds. EveryList<E>already is aKalendarEvents, becauseListis covariant.Where the generic stops.
kalendar-foundation's block engine —scheduleBlocks,resourceBlocks,KalendarScheduleBlock— stays typed onKalendarEvent, and the Schedule and Resource views re-attachEon the way back out. The engine is geometry: it packs minute ranges and carries each event along untouched, so nothing it computes could be more precise thanKalendarEvent, and parameterising it would put a type argument on every internal signature for no expressiveness at all. The price is one unchecked cast per view, at the single boundary where the event comes back, documented and covered byKalendarDomainEventTypeUiTest— one cast inside the library instead of one in every consumer's callback.Kalendar(type = ...)andKalendarTypeare gone. The last remaining type,Agenda, is now the top-levelcom.himanshoe.kalendar.KalendarAgendacomposable, so every view in the library is called directly. There is no dispatch entry point left; replaceKalendar(type = KalendarType.Agenda, events = events, config = KalendarConfig(...))withKalendarAgenda(events = events, config = KalendarViewConfig(...)).KalendarAgendarewritten as a first-class modern view. It is public, lives incom.himanshoe.kalendar, takesKalendarViewConfig, and draws entirely with Compose Foundation — no Material widgets, no hardcoded colours, shapes or text sizes. Every visual comes fromKalendarTheme, so it restyles with the other views. New slotsdateHeader,eventContentandemptyStatedefault toKalendarAgendaDefaults.DateHeader/.EventRow/.EmptyState. Its date headers now go throughKalendarViewConfig.monthNameFormatter(they used the raw EnglishMonthenum name and could not be localized), and its empty-state text comes from the newKalendarStrings.agendaEmptyState.kalendar-foundationis now 2.0.0 (was 1.1.0 stable), aligning it withkalendaron one major. The following had zero read sites anywhere in the project and were deleted:KalendarConfig,KalendarDayConfig,KalendarDayLabelConfig,KalendarHeaderConfig,OnDaySelectionAction,LocalDate.onDayClick,KalendarSelectedDayRange, and theKalendarColorsealed class with itsColor.asSolidColor()/List<Color>.asGradientColor()extensions.KalendarConfigin particular published 16 fields of which the Agenda view read exactly two; the rest were inert. What remains inkalendar-foundationis the event model:KalendarEvent,BasicKalendarEvent,KalendarEvents. Configure views withKalendarViewConfigand style them withKalendarTheme.Removed
KalendarType.Oceanic,Firey,Solaris,Aerial, andYearly(and thekalendar-foundationinternals they depended on) entirely. Replace them with the standalonecom.himanshoe.kalendarcomposables below — there is noKalendarType-based drop-in replacement; callKalendarWeek/KalendarMonth/KalendarYear/KalendarTimelinedirectly.KalendarColors,KalendarTypographyandKalendarShapeseach gained a required constructor parameter (agendaCard;agendaEventTitle/agendaEventSubtitle;agendaCard). Code thatcopy()s a defaults instance is unaffected; code that constructs one from scratch must supply them.
Added#
Events can be loaded per visible range instead of all at once, and the UI says when they have not arrived. Every view took its whole event list up front, so a calendar backed by a database or a network had two options: load the world before showing anything, or reload it on every swipe.
rememberKalendarEventLoader(state, source)sits beside the eager list rather than replacing it — a screen with events already in memory still passeseventsand pays nothing — and asks aKalendarEventSourcefor the range each page is about to draw. Four properties are the point of it, and are what a hand-rolled version usually misses: a range in the window is requested once, so sliding the window by a page asks only for the page that entered it; the window is the visible page plusprefetchPageseither side, so an ordinary swipe lands on a page that is already there, and a programmatic jump extends the window to its destination when the animation starts; a range the user has swiped past is cancelled, so a slow answer for a page nobody is looking at is neither waited for nor shown; and memory is bounded by the window, not by how far the user has scrolled. The range a page reports is the grid it draws rather than the period it is named after — March 2026 opens on a Sunday, so its page asks for 23 February through 5 April — because those leading and trailing cells show events like any other. A source isFlow-shaped so a Room query keeps the calendar current on its own, withKalendarEventSource.suspending { }for a one-shot network load. Events reaching the views are de-duplicated byKalendarEvent.id, since two adjacent ranges both legitimately return a multi-day event that straddles their boundary.A month with no events yet no longer looks like a month with no events. Once events can be absent-because-pending, the two have to be told apart. Each view gained a
loadingContentslot, handed aKalendarLoadingScopecarrying the range, itsKalendarLoadStatusand — when it failed — the failure and aretry. It defaults toKalendarLoadingDefaults.Placeholder, a wash drawn fromKalendarColors.dayContentso it follows the theme in both light and dark. The placeholder is a match-parent-size overlay, which is a layout guarantee rather than a coincidence: it is measured after the page has decided its own size, so events landing changes what is drawn and never where — pinned by a test that compares a day cell's bounds before and after. There is deliberately no shimmer: an indefinitely repeating animation never lets a Compose test go idle, so a shimmering default would hang the first UI test a consumer wrote around a loading calendar.KalendarAgenda, having no pages, uses the slot for the one case it does have — an empty list that is still filling.A calendar can be pinned to a time zone rather than the device's.
KalendarTimeSourcegained atimeZone, defaulting toTimeZone.currentSystemDefault(), and it reaches everything that turns an instant into a position: what "today" is, where the now-indicator sits, where a day boundary falls when a span is expanded, and which date a timed event belongs to. That last one is the reason it matters — an event at 23:00 UTC is today in London and tomorrow in Tokyo, so a booking tool showing an office's calendar has to ask the office's zone, not the viewer's phone.ProvideKalendarTimeSourcesets it for a subtree.Four zone-aware functions in
kalendar-foundationare public with it, because the engine has to answer the same questions without the views:Instant.dateIn(timeZone),LocalDateTime.sameInstantIn(source, target),LocalDate.dayLengthIn(timeZone)anddatesOfSpanIn(start, end, timeZone), plus theMAX_SPAN_DAYScap that keeps one malformed span from expanding without bound.dayLengthInexists because a day is not always 24 hours: it is 23 or 25 across a daylight-saving step, and neither whole nor round in the zones whose step is half an hour, so anything measuring a day by adding 24 hours to its midnight is wrong twice a year in most of the world. The hour grids deliberately do not divide their height by it — they are ruled in wall clock, which reaches 24:00 on every date there is.YearandTimelineare keyboard navigable, matching whatMonthandWeekalready did rather than inventing a second idiom for them. All four use the roving-tabindex pattern every native date picker uses: the grid is one tab stop, arrows move a focus ring between days without leaving it, andTabagain leaves — reaching 42 cells by pressingTab42 times is a tab trap with extra steps, not navigation.Home/Endgo to the first and last day of the period rather than the grid, soHomein a month reaches the 1st and not the leading padding day;PageUp/PageDownstep by the view's own unit, a year forKalendarYearand a month forKalendarTimeline. Arrows leaving the visible period scroll the period holding the target into view, so a boundary is invisible to the user. Disabled dates are focusable but not selectable — a keyboard user is told the date exists and is unavailable rather than having it silently skipped and a hole appear in the grid.Shift+arrow extends a range on the two views that have a range-drag gesture to mirror (Month,Week); without itonDateRangeSelectwould be reachable by pointer only, which is a WCAG 2.1.1 failure.A call site can override one theme token without constructing a theme.
KalendarTheme(tokens: KalendarThemeTokens?, content)takes a prepared bundle, so restyling one calendar isKalendarTheme(tokens = KalendarTheme.tokens.copy(dimensions = …))rather than spreading six token sets back over the six-parameter overload.tokens = nullinherits the surrounding theme untouched, passing the ambient bundle through by identity, which makes it a usable default for athemeparameter on a consumer's own composable — not overriding costs nothing and changes nothing. The bundle is provided unconditionally even fornull: wrapping conditionally would give the content a different group shape in the two cases, so a caller toggling an override on and off would discard the calendar's remembered state with it.Recurring events can be edited and deleted at one occurrence, at this and following, or across the series.
KalendarRecurrenceScopeis the three-way choice every calendar app offers, andKalendarSyncEvent.editOccurrence(occurrenceDate, edited, scope)/deleteOccurrence(occurrenceDate, scope)turn the answer into aKalendarSeriesChange— at most one write to the stored series row, plus the rows that have to be inserted. Both are pure functions; nothing touches a device until the change is applied.What each scope does to the data is the part a backend has to reason about.
THIS_OCCURRENCEkeeps the rule and adds an EXDATE, and an edit additionally produces a detached one-off event that must not inherit the rule — a copy that still carried it would resurrect the whole series under a second id.THIS_AND_FOLLOWINGterminates the stored row withUNTIL = occurrenceDate - 1 day, since RFC 5545'sUNTILis inclusive and the occurrence at the split has to be lost while the one before it is kept, and starts a new series at the split. ACOUNTis divided rather than copied — carried across, both halves would run the full count — and is counted with exception dates cleared, because EXDATE removes occurrences from a determined series rather than shortening it, so an excluded date still consumed one. A split at the series' own first occurrence rewrites in place instead of leaving anUNTILbefore its ownDTSTART.ENTIRE_SERIESrewrites the row in place under its original id, shifting the anchor, the span and every exception date by the same number of days when the occurrence moved. A change never both deletes the series and creates events, so applying the series write before the inserts cannot leave an occurrence existing twice.Multi-day events draw as one continuous bar, not as loose dots. A three-day conference used to render as three dots on three days, which is exactly what three unrelated single-day events look like — the month grid had no way to say "this is one thing that lasts". An event with an
endDatenow draws a bar running edge to edge across every day it covers, breaking at the week boundary the way a calendar has to, so a Thursday-to-Tuesday event is two bars. The event's real start and end are inset and rounded; an end that is only the row running out stays square and flush, which is what distinguishes a conference that finished on Saturday from one that continues into Sunday. Overlapping spans pack into lanes by the same greedy interval sweep the schedule grid already uses for overlapping blocks, so a bar holds one height for its whole run instead of climbing a staircase. Five dimension tokens control it:KalendarDimensions.spanBarHeight(4.dp),spanBarSpacing(2.dp),spanBarCornerRadius(2.dp),spanBarEndInset(3.dp) andspanBarMaxLanes(2). A span past the last lane is not hidden — it falls back to the indicator dot it drew before and still reaches the+Noverflow, so the cap costs continuity, not the event.spanBarMaxLanes = 0turns bars off entirely and restores the old rendering. The property this could not break, and did not: every day number in a grid sits on one baseline whatever its day holds — measured across the week of the 9th at 221, 222, 221, 221, 222, 222.API invariants are enforced against the checked-in ABI dumps, not against memory.
KalendarApiInvariantsTestasserts four rules that were previously obeyed by discipline alone and are exactly the kind quietly broken by a one-line change that looks harmless. (1) Nodata classreaches the public API — adata classcompiles onecomponentNper property plus acopywhose name carries a signature hash, so adding a property breaks every consumer at link time;KalendarEventhas gained two properties since 1.0, so this is live rather than hypothetical, and it is why eight public types hand-writecopy/equals/hashCode/toString. (2) No composable takes aState<T>orMutableState<T>parameter — it makes a plain value impossible to pass and leaks a storage decision into a signature, and a() -> Tbuys the same deferral without the coupling. (3)kalendar-foundation's ABI references nothing from the UI module, so the engine stays reachable without it. (4) No compiler-generatedComposableSingletons$*lambda holders appear in a dump — their accessors carry a hash of the lambda's source position and re-hash whenever an unrelated edit moves a lambda, which is how a real ABI change once went through review unnoticed; they are excluded viaignoredClasses, and this notices when a new one appears. BecauseapiCheckalready guarantees the dumps match the source, asserting against the dumps is the same as asserting against the API, and costs nothing at runtime.KalendarEvent.id— an optional stable identifier (nullby default, so existing implementations are unaffected). Views that keep per-item state use it as their list key, and it gives consumers a way to look an event back up instead of matching on name and date.BasicKalendarEventgained a matching trailingidparameter.Agenda theme tokens —
KalendarColors.agendaCard,KalendarShapes.agendaCard,KalendarTypography.agendaEventTitle/agendaEventSubtitle,KalendarDimensions.agendaRowPadding/agendaRowSpacing/agendaRowGap/agendaDotSize, andKalendarStrings.agendaEmptyState, soKalendarAgendarestyles and localizes like every other view.Recurring events write to device calendars —
insertEvent/updateEventnow persistrecurrenceRuleon Android (RRULE + RFC 2445 DURATION — recurring rows must not set DTEND — plus EXDATE) and iOS (EKRecurrenceRulemapping, withEKSpanFutureEventson rule updates so EventKit applies them); fetch round-trips the rule on both platforms. iOS cannot writeexceptionDates(no EventKit EXDATE API) — documented.Drag to move/resize Schedule blocks — press-and-hold a block to drag it to a new time (bottom-edge drag resizes; horizontal drag on the week grid moves across days), snapped to a configurable step, with live preview and overlap re-packing;
onEventTimeChange(event, newStart, newEnd)fires on release.Month/year jump picker — tapping the header title opens a year-stepper + month-grid popup that scrolls straight to the chosen month, on every header-bearing view.
Configurability pass —
KalendarViewConfiggainsshowJumpPicker,hourLabelFormatter(12h/24h gutter labels),scheduleInitialScrollHour,scheduleDragSnapMinutes, andeventIndicatorCap;doc/Config.mdrewritten around the realKalendarViewConfig(it previously documented a deleted 1.x API).Phone-framed demo site — the wasm demo now renders the sample app inside a phone mockup on a gradient landing page (pure Compose: hero title, GitHub/Docs link chips, punch-hole bezel), falling back to full-bleed on narrow viewports.
KalendarTheme/KalendarColors— every view colour now resolves through an ambient token set (selection, today/day/label content, event accent, now-indicator, grid lines, drag ghost). Material apps need no change (tokens derive from the ambient colour scheme); any app can restyle by wrapping views inKalendarTheme(colors = ...).KalendarScheduleWeek— 7-day time grid paged by week: day columns share one hour gutter, overlap packing per column, day-header row with today emphasised, all-day chips prefixed with day-of-month, now-indicator on today's column.Saveable selection —
rememberKalendarSelectionStatenow usesrememberSaveable, so selection (including an in-progress range) survives rotation and process death.Golden-image tests — desktop screenshot tests lock in the month grid (default and events/overflow) and the schedule day view; regenerate intentionally changed visuals with
./gradlew :kalendar:desktopTest -PupdateGoldens=true.Live demo deployment — a
Deploy DemoGitHub Actions workflow publishes the sample's wasm build to GitHub Pages on every push tomain; README links the demo.Calendar picker in
kalendar-sync—KalendarSyncProvider.listCalendars()returns the device's calendars (KalendarSyncCalendar), andinsertEventtakes an optionalcalendarIdto write into a specific calendar instead of the platform default.Reminders in
kalendar-sync—KalendarSyncEvent.reminders(KalendarReminder, minutes before start) written/read on Android (CalendarContract.Reminders), iOS (EKAlarm), and the Desktop.icsprovider; encoded as RFC 5545VALARMblocks by the iCal codec.Attendees in
kalendar-sync—KalendarSyncEvent.attendees(KalendarAttendee) on Android (CalendarContract.Attendees) and viaATTENDEElines in the iCal codec. On iOS attendees are read-only (EventKit cannot add them programmatically): populated on fetch, ignored on write.KalendarSchedule— hourly time-grid day view paged by day: timed events as duration-sized blocks with side-by-side packing for overlaps, all-day chips above the grid, a minute-refreshed now-indicator on today, and an initial scroll to early morning.Drag-to-reschedule on
KalendarMonth— press-and-hold a date with events and drag to another date;onEventDropreceives the events and the drop date, with a ghost highlight following the pointer. Composes with drag-to-select (event-less dates still range-select).Per-event indicator dots — day cells show one dot per event coloured by
KalendarEvent.eventColor(theme-primary fallback), capped at three with a+Noverflow label.KalendarWeek,KalendarMonth,KalendarYear— standalone composables backed by a sharedKalendarViewState, navigable by both arrow-click and swipe (HorizontalPager) through the same state so the two input methods never desync.KalendarTimeline— a continuous, virtualized vertical scroll through months (Google Calendar-list style), optionally bounded byminDate/maxDateonrememberKalendarTimelineState, unbounded by default.KalendarViewConfig— shared visual/behavioural config (disabledDates,showNavigationArrows,showAdjacentMonthDates,showTodayButton,background) for all four views above.Today button —
KalendarWeek/Month/Year's header, andKalendarTimeline's new sticky header, show a button that animates back to today's date. Enabled by default (KalendarViewConfig.showTodayButton = true).Sticky header on
KalendarTimeline— the topmost visible month's title and the today button now stay pinned above the scrolling content instead of scrolling away with it.Sliding selection indicator on
KalendarWeek/KalendarMonth— when exactly one date is selected and no customdayContentis supplied, the highlight animates smoothly from the previous cell to the newly tapped one instead of each cell fading independently. Snaps instantly (no fly-in) the first time a grid page appears, and falls back to independent per-cell fades for multi-select/range.KalendarSelectionMode+rememberKalendarSelectionState— a hoisted-state helper implementing single/multiple/range tap behaviour forselectedDates/onDateClick, so common selection modes don't need to be hand-rolled.Drag-to-select a range on
KalendarWeek/KalendarMonth— press and hold a date, then drag, to select a range live via the newonDateRangeSelect/onDateRangeSelectEndparameters (paired withKalendarSelectionState.onRangeDrag/onRangeDragEndfor the common case). Requires a long-press before the drag starts, so a quick tap is unaffected.Locale-aware formatters —
KalendarViewConfig.monthNameFormatter/dayOfWeekLabelFormatterlet you plug in a platform locale API (java.time,NSDateFormatter,Intl, ...) for month names and day-of-week labels; kotlinx-datetime has no locale-aware formatting of its own, so both default to plain English.KalendarSyncEvent.exceptionDates— the RFC 5545 EXDATE equivalent forkalendar-sync: skips specific dates when a recurring event is expanded viaexpandOccurrences, and round-trips throughexportToIcs/importFromIcsas anEXDATEline.Accessibility semantics on day cells — every day cell now exposes a screen-reader
contentDescription(e.g. "August 12, 2026, today, has events") and aselectedsemantic state, and usesRole.Buttonso assistive tech announces it correctly.KalendarEvent.endDate— multi-day event spans. A day cell shows an event on every date fromdatethroughendDateinclusive; expansion is capped at 366 days to guard against runaway spans.Selection, press, and event-indicator animations (
animateColorAsState, press-scale,AnimatedVisibility) and a title crossfade in the shared navigation header.kalendar-syncmodule — reads/writes the real device calendar with no third-party account:CalendarContracton Android,EventKiton iOS, a file-backed.icsprovider on Desktop. Includes RFC 5545 recurrence (KalendarRule,KalendarRecurrenceExpander) and pure in-memory iCal encode/decode (exportToIcs/importFromIcs) available on every platform including wasmJS.KalendarSyncEvent.endDatecarries multi-day spans through insert/update/fetch and iCal round-trips on every platform provider.Sample app now has a view switcher covering every view type (Week, Month, Year, Timeline, Schedule, Week grid, Agenda).
explicitApi()mode enabled across all three published modules — every public declaration now requires an explicit visibility modifier and return type, guarding against accidental API leaks.kotlinx-binary-compatibility-validatorwired into all three published modules and CI (apiCheck), so an accidental breaking change to the public API surface now fails the build.Root
.editorconfigdocumenting this project's formatting conventions (4-space indent, 140-char line length, no forced trailing commas) for editors and the existingdetekt-formatting(ktlint-based) ruleset to read.
Fixed#
KalendarGridno longer writes snapshot state from the layout phase. It tracked where each day cell landed in amutableStateMapOfwritten fromonGloballyPositionedand read from its own composable body to position the selection indicator — a cross-phase back-write that cost one whole extra composition of the grid per page composed. The map is a plainremember { mutableMapOf() }now, and no reader is in composition: the gesture code reads it from a pointer callback, the overlays from their layout lambdas, and the indicator's target is computed inside asnapshotFlowin the effect that drives its animator. The one thing a plain map gives up — telling a reader the cells have moved — comes back as a singleIntrevision bumped only on a placement that actually changed; bumping it on every callback would invalidate the placement that produced the callback and the grid would lay itself out forever. Measured on a March 2026 month: snapshot-map writes on a first frame 37 → 0, grid-body compositions 2 → 1, grid content recompositions during a six-move drag 7 → 0.layingOutAMonthGridWritesNoSnapshotMapguards it by counting the cause rather than the symptom, since a lazy grid memoizes item content by key and the second composition walked no cells to observe. Two earlier claims about this were wrong and are corrected here: the writes did not recur on every layout pass (SnapshotStateMap.putof an equal value is a no-op, so the real cost was one extra whole composition per page, not per-frame churn), and there was never a "404 writes for a year" —KalendarYearcomposes throughKalendarStaticGrid, which never tracked coordinates at all; 404 was the cell count of the old eager year column. The naive version of this fix moves the reads to the layout phase and silently stops the indicator animating, because theLaunchedEffectkey is itself a composition read — the first-paint-flash and post-offset test-tag tests are what keep that honest.KalendarAgendano longer collides an eventidwith an item index. Items were keyed"event-$day-${event.id ?: index}", which put the id space and the index space in one space: an event carrying theid"1"beside an id-less event sitting at index 1 produced identical keys, and aLazyColumnthrows on a duplicate rather than degrading, so the whole agenda crashed. The mix that triggers it is ordinary rather than exotic — events synced from a device arrive with ids and locally drafted ones do not, so any consumer doing both on one day hit it. The two fallbacks are namespaced apart now (id-$idagainstat-$index), which is sufficient because neither prefix can be produced by the other. Found by an adversarial review probe rather than by any existing test; the probe is kept as a regression test, with a second case one step along.KalendarAgendano longer crashes on duplicate events. Its list keyed items on"${date}_${eventName}_${startTime}", so two all-day events with the same name on one date produced identical keys andSaveableStateHolderthrewIllegalArgumentException— reliably, since the stable sort placed the colliding pair adjacent. Items are now keyed on the newKalendarEvent.id(falling back to the event's position within its date) plus the date it is rendered under.- On iOS 17+ the sync provider requested calendar access with the deprecated pre-17 API, which
can grant only write access and break event fetching; it now calls
requestFullAccessToEventsWithCompletionwhen available (runtime-gated), falling back to the legacy request on older iOS. - Drag gesture handlers on the Schedule views and the month grid captured their first
composition's state forever (a
pointerInputcoroutine launches once and never sees later lambdas): after the events list changed, the next drag moved/dropped events using stale blocks and stale callbacks. All gesture callbacks now read throughrememberUpdatedState, and a regression UI test drags the same block twice across an events update. - On
KalendarScheduleWeek, dragging a block horizontally computed the day shift from the block's own width instead of the day column's width, so drags across days landed on the wrong day whenever events overlapped (blocks share a column side by side). Day shift is also now clamped to ±6 days. - Dragging a block to the bottom of the day reported an end time of 23:59 on the same day instead of 00:00 on the next day, silently shortening the event by a minute.
- Drag snapping rounded twice (pixels → minutes → snap step), drifting up to half a step for snap steps that don't divide 60; it now rounds once, directly to the snap step.
KalendarTimelinecrashed (IndexOutOfBoundsException) or wrapped to a negative index when scrolled to the edge of aminDate/maxDate-bounded range, and when only one bound was set the index math could overflowInt. Index arithmetic is now done inLongand clamped.- The header's previous-arrow disabled itself one page too early when
minDatefell mid-period (e.g. aminDateof the 15th made its own month unreachable), andanimateScrollTowith an out-of-bounds date scrolled past the configuredminDate/maxDateinstead of clamping to it. KalendarRecurrenceExpanderwith acount-limited rule invented occurrences when the query window started after the series began: the count was only consumed inside the window, so aDAILY COUNT=3series from January 1st still produced events in February. The count is now consumed from the series start.- Recurring rules that combine
intervalwith MONTHLYbyDay, or YEARLYbyMonth, never produced any occurrences (the expander stepped by whole months/years and missed the filter days). YEARLYbyMonthalso correctly pins the day-of-month from the series start per RFC 5545. - All-day events written to Android used local-midnight timestamps instead of the UTC midnights
CalendarContractrequires, shifting them a day in negative-offset timezones; reads now convert back through UTC as well. - Timed recurrence exceptions (
exceptionDates) written to Android used wall-clock time with aZsuffix, so the platform matched no instance and the exclusions silently failed; EXDATE values now convert through the device timezone to real UTC instants (and parse back the same way). - Fetching events on iOS excluded the final day of the requested range (predicate ended at the range end's start-of-day instead of the day after).
- The iCal codec stamped
Zonto wall-clock times on export, silently shifting every timed event for consumers in non-UTC timezones; it now emits RFC 5545 floating times (legacyZinput still decodes). - Expanded recurrence occurrences dropped their template's
exceptionDates;DURATIONvalues using weeks (P1W) failed to parse; day-basedVALARMtriggers (-P1D) were ignored; Android fetch hid recurring series that started before the query window; iOS all-day fetches reported an end date one day off. kalendar-syncdid not actually compile for iOS or wasmJS —commonMainused JVM-onlyString.format/SecurityException, and the iOS EventKit integration referencedEKAuthorizationStatusAuthorized(deprecated in iOS 17, replaced byEKAuthorizationStatusFullAccess) and anNSDateconstructor signature that doesn't exist in the generated Kotlin/Native bindings.- Multi-day events (
endDate) were silently dropped when a recurring event was expanded into occurrences, and when the Desktop/Android/iOS sync providers inserted or updated an event. KalendarRulewith a multi-valuebyMonthDay(e.g. the 1st and 15th of every month, as shown in its own documentation) only ever produced one occurrence per month instead of both.KalendarRule.intervalwas silently ignored when combined with WEEKLYbyDayor MONTHLYbyMonthDayfilters — an every-other-week rule fired every week. Interval now composes with the BY-filters, and the previously documented limitation is removed.- The month/week grid's internal
LazyVerticalGridscrollable consumed vertical drags before the long-press drag gestures (drag-to-select, drag-to-reschedule) could see them, cancelling the gesture; internal grid scrolling is now disabled (the grid never needed it — pages are fixed height). - Nested-lazy-layout crash on Android/iOS in
KalendarTimeline/KalendarYear(aLazyVerticalGridinside an already-scrollable parent) — replaced with the eagerKalendarStaticGridin both. - Grid cells stretched to fill the row on trailing partial weeks (fewer than 7 days).
[2.0.0-RC1] – 2026#
Breaking changes from 1.x#
- The library has been fully migrated to Kotlin Multiplatform (Android, JVM/Desktop, iOS, wasmJS).
kalendarnow depends on the newkalendar-foundationmodule which must be added separately if you need direct access to foundation types.KalendarTypeis now a sealed interface instead of an enum. ReplaceKalendarType.OCEANICstyle usages withKalendarType.Oceanicdata-object references.- Day-click callbacks now receive
List<KalendarEvent>(events on that day) in addition to the tappedLocalDate. - Selection behaviour is now configured through
OnDaySelectionActioninstead of individual boolean flags.
Added#
- Four calendar variants via
KalendarType:Oceanic— full month grid with previous/next arrow navigation.Firey— single week row with previous/next arrow navigation.Solaris— swipeable full month grid (infiniteHorizontalPager).Aerial— swipeable week row (infiniteHorizontalPager).
OnDaySelectionActionsealed class —Single,Multiple, andRangeselection modes.KalendarSelectedDayRange— typed value for range selection with compile-time start/end fields.KalendarEvents/KalendarEvent/BasicKalendarEvent— typed event model with per-day event indicators (up to 3 dots).KalendarKonfig/KalendarDayKonfig/KalendarHeaderKonfig/KalendarDayLabelKonfig— composable configuration objects for colours, sizes, and text styles.KalendarColor— sealed class forSolidandGradientcolour values used throughout the configuration API.startDayOfWeekparameter — configure the first column of the week grid.restrictToCurrentWeekOrMonthflag — prevent back-navigation past the current period.- wasmJS target support.
Fixed#
isCurrentMonthnavigation guard now correctly handles cross-year navigation (e.g. viewing January 2026 from December 2026 no longer disables back navigation).
[1.0.0]#
Initial release supporting Android-only Jetpack Compose with basic month and week calendar views.