-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzCalendar
A calendar surface with month, week, and day navigation, event display, and disabled-date support.
Metadata
Predecessor: None — no ThemedCalendar exists in layrz_theme
Phase: M6 (pulled forward as an M4/M5 prerequisite)
Domain: Data
Primitive: Hand-rolled (Column/Row/LayrzCard, no Material CalendarDatePicker)
Status: Merged · Review required.
LayrzCalendar is a thin coordinator, in the same template LayrzStepper established: it owns
a LayrzCalendarController lifecycle and delegates all grid painting to whichever per-mode layout
surface the active mode selects — LayrzCalendarMonthSurface, LayrzCalendarWeekSurface, or
LayrzCalendarDaySurface. It renders navigation chrome (previous/next/today buttons, a period
label, and a view-mode switcher) above that surface.
LayrzCalendar(
entries: [
LayrzCalendarEntry(
title: 'Team standup',
start: DateTime(2026, 8, 28, 9),
end: DateTime(2026, 8, 28, 9, 30),
),
LayrzCalendarEntry(
title: 'Conference',
start: DateTime(2026, 8, 27),
end: DateTime(2026, 8, 29),
color: tokens.colors.warning.shade500,
),
],
isDateDisabled: (date) => date.weekday == DateTime.sunday,
)This is display-and-navigate, plus a coordinate handed back on tap — still not a selection
surface. onTap hands the caller a DateTime when empty calendar space is tapped; the calendar
does not remember it as "the chosen one," has no return value of its own, and keeps no selection
state. See "Tap callbacks" below for the full contract, including how tapping an event entry is a
completely separate path from onTap. Two other targets keep navigating internally instead of
calling out to the caller: a month cell's day-of-month number (see dayNumberOpensDayView) and the
month grid's week-number gutter (see showWeekNumbers) — these navigate to day/week view for that
date without exposing anything new, observable only through the existing onModeChanged. See
engineering/decisions.md's D72 for why this still falls short of what a date picker needs (a
persisted selection model and a compact chrome-free grid), and is not meant to.
- Month view: a full grid, one row per week, seven columns.
- Week view: seven day columns sharing one hour axis and one all-day/multi-day band across the top.
- Day view: a single column with a fixed 24-row 00:00–23:00 hour axis in a scroll view.
The header's view-mode switcher is unlocked for all three — each button dispatches through
onModeChanged when tapped, and the active mode renders as LayrzButtonStyle.elevated while the
other two render LayrzButtonStyle.outlined. (An earlier pass shipped the switcher with the week
and day buttons permanently disabled; that restriction is gone.)
Header layout: previous/next navigation and the period label are grouped together at the
leading edge ([◀] August 2026 [▶]), with the Today button at the trailing edge of the same row.
A correction to a scope claim made in the first version of this page: engineering/decisions.md's
D11 does name four view modes — day, week, month, and year — as part of its original full-refactor
scope review for this component. Three of the four ship as of this pass. No year view exists,
and this correction is documentation only — it is not licence for year-view work.
Month names and AM/PM markers are now localizable. A new l10n namespace
(LayrzUiL10nMonthsMixin) supplies monthJanuary…monthDecember and timeMeridiemAm/
timeMeridiemPm, resolved through LayrzUiL10n.of(context) everywhere a month name or meridiem
marker renders (the header's period label, day-cell semantics labels, the overflow chip's semantics
label, and the hour axis). Previously month names came from a hardcoded English array.
Disabled dates and "no events" are distinct code paths. A disabled date is purely visual in this pass (nothing is tappable on a disabled date beyond the date number and the overflow chip, neither of which is affected by disabled state) and never shares a render branch with an empty day. A disabled day with events still renders those events at their ordinary colors — disabled styling never dims event chips — and a non-disabled day with no events is an entirely ordinary empty cell.
No text anywhere in the calendar is selectable. The whole widget is wrapped in
SelectionContainer.disabled, covering every Text the calendar and its surfaces render — present
and future — with one wrapper rather than each call site opting in individually. This has no effect
on any interactive element: SelectionContainer.disabled resolves to a plain InheritedWidget
override with no gesture detector and no semantics node of its own, so taps, hover, and semantics
throughout the calendar are unaffected. This is about text selection (e.g. double-click to select
a date number's digits) and is unrelated to the day/event "selection" language used elsewhere on
this page for a caller's own data model.
void Function(DateTime date)? onTap fires when the user taps empty calendar surface: a month
cell's body outside any event chip, or an hour-grid slot outside any timed block. It never fires
for a tap that lands on an event entry — see LayrzCalendarEntry.onTap below for that path. Null
(the default) leaves every surface it would otherwise govern exactly as display-only as before this
parameter existed: no hover affordance, no pointer cursor, and no interactive semantics node.
The payload's precision depends on the active LayrzCalendarMode:
- Month view: the tapped date at midnight — hour, minute, second, and millisecond all zero.
- Week and day views: the tapped date and time, snapped to the nearest 15-minute boundary at or before the tapped vertical offset within its hour row, with seconds and milliseconds always zero.
The snap governs the returned value, not the hit target. A whole hour row remains one tappable region — a 15-minute slice of it is not separately hit-tested — so a caller wiring "create an entry here" gets a value already rounded to a sensible granularity, rather than a raw pointer-position timestamp it would have to re-round itself.
There is no onEntryTap callback on LayrzCalendar. Instead, LayrzCalendarEntry itself
carries a VoidCallback? onTap field, wired per entry by whoever constructs it. Tapping a rendered
entry — a chip in a month cell, a multi-day bar, an all-day band bar, or a timed block in the
week/day hour grid — fires that entry's own onTap, and only that entry's onTap; it never also
fires LayrzCalendar.onTap, because the entry's own tap target sits above the surface's.
onTap is a plain notification with no parameter — the caller decides what happens next (open a
dialog, navigate, anything else). This is deliberate: the closure a caller assigns is written in
the same scope that already has the entry's (or its subclass's) own data available, so there is
nothing meaningful to hand back that the closure doesn't already have.
Extension point: consumers are expected to subclass LayrzCalendarEntry. The calendar's own
data model is deliberately minimal — title, start, end, color, preview flag, and a bare onTap —
because a real app's event almost always needs more (a database ID, a full domain object). Rather
than growing the base class to anticipate every such need, the intended pattern is to subclass:
class MyEvent extends LayrzCalendarEntry {
const MyEvent({
required this.recordId,
required super.title,
required super.start,
required super.end,
}) : super(onTap: null); // wire onTap per-instance in the constructor body or via a factory
}The caller builds MyEvent instances — each free to wire its own onTap closure — and passes them
to LayrzCalendar.entries. LayrzCalendar never reconstructs or copies an entry it did not create
itself; every widget in this module holds the exact instance it was given, so the object in scope
when onTap fires already is the caller's own MyEvent. There is never a cast to recover it.
Equality contract a subclass must observe. LayrzCalendarEntry's operator == checks
runtimeType before any field, so a MyEvent instance never accidentally compares equal to a
plain LayrzCalendarEntry, or to an instance of a different subclass, no matter how their base
fields line up. What base equality does not protect against: two instances of the same
subclass that differ only in fields the subclass itself added compare equal, because the base
operator ==/hashCode know nothing about them. A subclass that adds fields meaningful to
equality must override both, typically as:
@override
bool operator ==(Object other) => other is MyEvent && super == other && recordId == other.recordId;
@override
int get hashCode => Object.hash(super.hashCode, recordId);onTap itself is deliberately excluded from both operator == and hashCode on the base class,
and a subclass's override should keep it excluded too — closures compare by identity, so two
entries with otherwise-identical data but separately-written closures (e.g. two
MyEvent(recordId: 1, onTap: () {...}) built from the same data at different times) would
spuriously compare unequal if it were included, which would break widget diffing and set/map
membership for no benefit.
A month cell has four distinct tap regions, each with its own callback and its own gating — do not collapse two of them into one handler:
- The date number → opens day view, gated by
dayNumberOpensDayView(defaulttrue). - The "+N" overflow chip → opens day view, gated only by its own presence — not by
dayNumberOpensDayView. Regions 1 and 2 reach the same destination but are reached through separate callbacks with separate gating; collapsing them into one handler would silently make the "+N" chip obey the date number's flag, which is not the contract. - An event chip or multi-day bar → fires that entry's own
onTap. There is no callback on the cell or onLayrzCalendarfor this region — it is wired entirely from the entry the caller constructed. -
Anywhere else in the cell body → fires
LayrzCalendar.onTapwith that date at midnight.
Tapping an entry fires the entry's own callback only, never also the calendar's onTap — an
entry's GestureDetector sits above the cell body's, so a tap that lands on a chip is consumed
there and never reaches the cell-level handler.
When LayrzCalendar.onTap is non-null, the surfaces it governs (a month cell's empty body, an
hour-grid row) show a hover state and pointer cursor. When both it and every entry's own onTap
are null, the calendar remains exactly as display-only as before either parameter existed — no
hover, no cursor, no interactive semantics anywhere these callbacks would otherwise apply.
| Parameter | Type | Notes |
|---|---|---|
controller |
LayrzCalendarController? |
Optional. If null, the calendar creates, owns, and disposes its own. If non-null, the caller owns disposal, and the instance must never be swapped on rebuild — an assertion fails (debug builds) if a different controller is passed. |
entries |
List<LayrzCalendarEntry> |
Defaults to an empty list. An entry is placed wherever it occupies — a multi-day entry appears once per day, week column, or hour grid it spans, depending on the active mode. |
isDateDisabled |
bool Function(DateTime date)? |
A predicate, not a set/range, so open-ended rules ("weekends", "dates before today") express without precomputing a bounded collection. Purely visual in this pass. |
initialMode |
LayrzCalendarMode |
Used only when controller is null. Defaults to LayrzCalendarMode.month. |
initialDate |
DateTime? |
Used only when controller is null. Defaults to the current date. |
firstDayOfWeek |
int |
One of DateTime.monday (1) through DateTime.sunday (7); asserted at construction. Defaults to DateTime.sunday. Applies to both month and week modes; day mode has no columns to order. See "Breaking change" below. |
timeFormat |
LayrzTimeFormat |
Defaults to LayrzTimeFormat.h24. Governs the week/day surfaces' hour axis and timed-event time rendering. Has no effect on month view. |
dayNumberOpensDayView |
bool |
Defaults to true. Whether tapping a month cell's day-of-month number navigates to day view for that date. When false, the number renders fully inert — no hover state, no pointer cursor, no interactive semantics node. Has no effect on the "+N" overflow chip, which always navigates regardless. |
showWeekNumbers |
bool |
Defaults to true. Whether a week-number gutter renders to the left of the month grid. See "Week-number gutter" below. |
onModeChanged |
void Function(LayrzCalendarMode mode)? |
Notification, not a gate — the controller's mode has already changed by the time this fires. Also fires when a month cell's date number, overflow chip, or the week-number gutter internally navigates to day or week view. |
onTap |
void Function(DateTime date)? |
The calendar's only tap callback. Fires for a tap on empty calendar surface only — never for a tap on an event entry (see LayrzCalendarEntry.onTap), the date number, or the overflow chip. See "Tap callbacks" below for the full precedence contract and the mode-dependent payload precision. |
Mirrors LayrzStepperController's contract: the controller owns state, the widget is a pure
observer subscribing via addListener.
| Member | Notes |
|---|---|
focusedDate |
The date currently in view. For month mode, any date within the visible month — the grid derives from year/month only, ignoring the day. |
mode |
The current LayrzCalendarMode. All three values render. |
nextMonth() / previousMonth()
|
Moves focusedDate by one month. |
nextWeek() / previousWeek()
|
Moves focusedDate by exactly 7 calendar days, stepped via calendar-field arithmetic (never Duration) so a DST transition never lands on the wrong local day. |
nextDay() / previousDay()
|
Moves focusedDate by exactly one calendar day, same DST-safe stepping. |
goToToday() |
Sets focusedDate to today. |
goToDate(DateTime date) |
Sets focusedDate to an explicit date (time-of-day discarded). |
setMode(LayrzCalendarMode newMode) |
Sets mode and notifies listeners. |
dispose() |
Caller-owned if the controller was caller-supplied; calendar-owned otherwise. |
The header's previous/next buttons and their tooltip/label text are mode-aware: in month mode they
read "Previous/Next month" and dispatch to previousMonth/nextMonth; in week mode, "Previous/Next
week" and previousWeek/nextWeek; in day mode, "Previous/Next day" and previousDay/nextDay.
Previously these always moved by a month and always announced "Previous/Next month" regardless of
the active mode.
An immutable data class (@immutable, copyWith, ==/hashCode).
| Field | Type | Notes |
|---|---|---|
title |
String |
Required. Shown on the day cell, event chip, multi-day bar, or timed block. |
start / end
|
DateTime |
Required. end must not be before start — not asserted at construction (would block const event lists), so this is a caller obligation. |
color |
Color? |
Optional accent. Falls back to a token default when null. |
isPreview |
bool |
Defaults to false. Marks the entry as a provisional/ghost event rather than a committed one — see "Preview rendering" below. Participates in ordinary layout exactly like a committed entry: it takes a lane, is included in overlap resolution, and occupies a normal slot; only its paint treatment differs. |
onTap |
VoidCallback? |
Called when this entry is tapped — a chip in a month cell, a multi-day bar, an all-day band bar, or a timed block in the week/day hour grid. Null renders the entry non-interactive: no hover state, no pointer cursor, no interactive semantics node. See "Tap callbacks" above for the full contract, the subclassing pattern, and the equality caveat. Deliberately excluded from operator == and hashCode. |
isMultiDay compares only the year/month/day components of start and end — an entry from
23:00 to 01:00 the next day is multi-day even though it lasts two hours; an entry from 00:00 to
23:59 the same day is not, despite spanning nearly 24 hours. occupies(DateTime date) tells a
surface whether an entry should render for a given date, using the same date-only comparison over
the inclusive [start, end] range.
Equality and hashing: a plain value comparison of every field except onTap (closures compare
by identity, so two entries with identical data but separately-written closures would otherwise
spuriously compare unequal), plus the runtimeType check described above. Like LayrzStep,
color's comparison is by Color value.
An isPreview entry renders as a ghost of its ordinary chip/bar treatment: reduced opacity plus an
outline substituted for the solid fill, keeping the entry's own color so it still reads as which
event, at identical geometry to a committed entry — no size, padding, or margin difference — so a
preview occupies exactly the slot it would occupy once committed, and committing it never shifts
anything else in the cell. This applies uniformly across month chips, multi-day bars, all-day band
bars, and timed blocks in week/day view. A covered preview in the hour grid shows both the
ghosting and the ordinary covered-event demotion — the two compose rather than one overriding the
other.
month · week · day — all three render as of this pass.
amPm · h24 — the clock convention the week and day surfaces render hour-axis labels and
timed-event times in. Defaults to h24. Has no effect on month view, which renders event titles
only, never times.
LayrzCalendarDayCell / LayrzCalendarHeader / LayrzCalendarMonthSurface / LayrzCalendarWeekSurface / LayrzCalendarDaySurface
Supporting widgets exported for composition and reuse, but not typically constructed directly by
consumers — LayrzCalendar wires them together.
LayrzCalendarMonthSurface lays out a fixed 7-column × 6-row grid ordered from firstDayOfWeek,
with leading/trailing days from adjacent months filling out the first and last rows so the grid is
always a rectangle.
Breaking change: the grid's first weekday is now configurable, and the default changed.
Previously the grid hardcoded a Monday-first layout. firstDayOfWeek now defaults to
DateTime.sunday, so a caller that passes nothing sees a Sunday-first grid after upgrading.
Pass firstDayOfWeek: DateTime.monday to restore the previous layout.
Multi-day events render as one continuous bar per week row, not as a separate chip in every day
cell they cross. Each week row is a Stack: the ordinary row of seven day cells underneath, and one
bar per multi-day entry that intersects that week on top, spanning from its start column to its end
column (clamped to the row's range). A bar that continues past a week's boundary ends at that row,
and a second, independent bar segment starts the following row — there is no "continues" affordance
connecting the two. Single-day events are unaffected and still render as ordinary per-cell chips.
Multi-day bar lane assignment is stable for the whole month, not re-derived per week row — an entry keeps the same lane index across every week row it spans. A visible consequence: a day can show blank reserved lanes above its content, because lower lanes are held by entries that occupy that day only in a different week of the same month. This is correct and deliberate, not a rendering bug — do not "fix" it by switching to per-week packing.
The visible event cap is derived from measured cell height, not a fixed constant.
kLayrzCalendarMaxVisibleEvents is removed. Each week row measures its own available height via
a single LayoutBuilder and computes maxSlots = (availableHeight / kLayrzCalendarEventSlotHeight).floor(),
applied uniformly to every cell in that row and to the row's own multi-day bar cap, so the two never
diverge. Overflow collapses into a tappable "+N" chip, which navigates to day view for that date
when tapped (reversing an earlier pass's inert "+N" chip).
Tappable day number. dayNumberOpensDayView (default true) makes tapping a cell's
day-of-month number open day view for that date — the same action the overflow chip performs.
Week-number gutter. showWeekNumbers (default true) renders a column of tappable ISO 8601
week numbers to the left of the month grid, one per week row, each opening week view for that row
when tapped. Each row is labeled with the ISO week of its own first day — this matters because ISO
8601 defines weeks as Monday-start while the grid's own default start is Sunday, so most rows
straddle two ISO weeks; that overlap is expected and does not produce a duplicate or reversed
number in the gutter. Has no effect outside month view.
Grid lines are painted once, by the surface, not by individual cells. The whole grid is wrapped in a container filled with the divider color, and every cell is inset on every side so the container's background shows through the gaps as a single, uniform line — interior lines and the outer edge alike.
Typography: the weekday header row (Sun, Mon, …) renders in the full title type style;
each cell's day-of-month number renders in body's size with title's weight/family/letterSpacing
composed on top — sized down from a full title treatment that read as oversized once actually
rendered in a compact month cell, while staying heavier than plain body so it still reads as the
grid's structural content rather than muted background texture.
LayrzCalendarWeekSurface renders seven day columns, ordered from firstDayOfWeek, sharing one
left-edge hour axis and one all-day/multi-day band across the top. Each column header stacks the
weekday name above the date number.
LayrzCalendarDaySurface renders a single column with a fixed, always-visible 00:00–23:00 hour
axis in a vertical scroll view, plus an all-day band for any multi-day entry occupying the focused
date. The axis is never windowed or cropped to "working hours," and there is no initial
scroll-to-a-particular-hour behavior.
Hour labels are vertically centred in their rows — a deliberate deviation from the more common convention of pinning a label to the gridline marking where its hour starts. This is a vertical-only change: row height and the row boundaries themselves are unaffected, so the axis stays in lockstep with the timed grid underneath it.
Overlapping timed events split their column evenly among concurrent events. The later-starting event draws on top (ties broken alphabetically by title), and a covered event renders demoted to a lighter, outlined fill while the covering event keeps its ordinary solid fill. Short events receive a minimum block height so they stay legible even when very brief.
Week and day views share this rendering (HourAxis, HourGridColumn, AllDayBand) — day view's
single column is simply wider, making the same overlap-column scheme more comfortable there, not a
different implementation.
No persisted "selected date" concept, no onDaySelected. onTap reports a coordinate on each tap
without the calendar remembering it, and the day number, "+N" overflow chip, and week-number gutter
drive only the calendar's own view state (switching mode, moving focusedDate), observable through
the existing onModeChanged. See D72 in engineering/decisions.md for why this remains a
deliberate scope boundary rather than a capability gap.
Each month day cell merges its full chrome state into one Semantics announcement — e.g. "August
28, today, disabled, 2 events" — rather than exposing a separate node for the date number and its
event chips. Disabled state and event count are both announced explicitly, not conveyed by color
alone. The date number and the overflow chip, being independently interactive, each carry their own
live semantics node with a concrete action label (e.g. "August 28, opens day view") rather than
folding into the cell's merged label. An interactive event chip (non-null LayrzCalendarEntry.onTap)
likewise carries its own semantics node — a button labeled with the entry's title — separate from
the cell's merged chrome label; a non-interactive chip has no such node and folds back into the
merged label as before. A timed event block in week/day view is a real Semantics node carrying
the event's title, since nothing else announces it there.
-
Spacing:
sp1–sp2for cell padding and header gaps. -
Colors:
primary(today's highlight),sf1(cell background),sf2(disabled-date overlay in week/day view),sf3(the overflow chip's neutral fill),fg1–fg4(date number and text by state),divider(grid lines),info(default event accent). -
Typography:
label(event/overflow chip text, weekday abbreviations, hour-axis labels),title(period label, week/day column date numbers).
Component Catalog,
Milestone 6
Last updated: 2026-08-28 (pass 3 — LayrzCalendar.onTap, LayrzCalendarEntry.onTap and the
subclassing/equality extension pattern, isPreview rendering, disabled text selection, the
four-region month-cell tap contract, and header/typography device-review fixes; pass 2 shipped
week and day views, mode-aware navigation, firstDayOfWeek, LayrzTimeFormat, multi-day lane
packing, space-derived event cap, tappable day number and week-number gutter, and month/meridiem
localization)
Made with ❤️ by Golden M, Inc.
- LayrzAnchoredPanel
- LayrzBottomSheet
- LayrzDialog
- LayrzDropdownMenu
- LayrzResponsiveModal
- LayrzPageTransition
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider
- LayrzStepper