RemixRemix
Actions and inputs

Range Calendar

A controlled inclusive date range with shared calendar navigation and a local selection draft.

Open catalog
Starting Flutter example…
Live Flutter from the pinned Remix component catalog. Examples include Fortal styling; the source tab shows the catalog code, not a standalone application.

RemixRangeCalendar lets people choose a complete inclusive date range in one Gregorian calendar. It reuses Calendar navigation, day interaction and styling; it is not two independently selected dates. The application owns the committed CivilDateRange?; the calendar retains only an unfinished selection anchor. To open it from a button instead of placing it inline, use Date Range Picker.

Basic usage

import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';

class ReportingPeriod extends StatefulWidget {
  const ReportingPeriod({super.key});

  @override
  State<ReportingPeriod> createState() => _ReportingPeriodState();
}

class _ReportingPeriodState extends State<ReportingPeriod> {
  CivilDateRange? period;

  @override
  Widget build(BuildContext context) => RemixRangeCalendar(
    value: period,
    firstDate: DateTime(2026),
    lastDate: DateTime(2028, 12, 31),
    semanticLabel: 'Reporting period',
    firstDayOfWeek: DateTime.monday,
    onChanged: (next) => setState(() => period = next),
  );
}

The default month/year selectors need an Overlay ancestor. A routed WidgetsApp supplies one; use Overlay.wrap in a builder-only host. No Material theme, MaterialLocalizations or Material DateTimeRange is required.

Selection and ownership

ActionResult
First eligible activationStart a local draft; no callback.
Navigate after anchoringPreview the ordered interval from the anchor to the focused day; no callback.
Second eligible activationClear the draft and request one complete inclusive range.
Activate an unavailable date or cross an unavailable interior dateKeep the anchor; no callback and no silent truncation.
EscapeCancel the local draft; leave Escape available to an enclosing overlay.
Tab or blurPreserve the inline draft without committing.
Parent replaces or clears the valueShow the supplied value and cancel a stale draft.

Reverse selection orders endpoints before calling onChanged. Selecting the anchor again requests a one-day range. No partial value reaches the callback. If the parent rejects a request by leaving value unchanged, that supplied range remains selected; there is no optimistic local committed value.

CivilDateRange(start: ..., end: ...) normalizes the civil year/month/day fields to local dates where representable, with a UTC date-only fallback for skipped local days. It ignores input times and UTC flags and rejects reversed construction in release mode. Treat the preserved fields as civil dates without timezone conversion. Equality and hashing compare civil endpoints. Both endpoints are included. The UTC fallback preserves fields rather than converting a scheduling instant.

firstDate and lastDate are inclusive. enabled: false or null onChanged disables interaction and cancels an unfinished draft. Equal replacement range objects preserve browsing. Clearing the committed range does not reset the browsing month. Accepting the calendar's remembered request preserves focus and the displayed month at the completed end, including an asynchronous acceptance. The calendar remembers its last request until the user navigates (including month/year selectors), starts a new draft, the supplied value changes, or interaction is disabled (enabled: false or null onChanged). Any other changed non-null value recentres on its clamped start. This includes an equal-to-request value supplied after the original request was rejected and the user browsed elsewhere, started a new draft, or interaction was disabled and re-enabled.

Unavailable dates

isDateUnavailable must be a pure date predicate. Every date in a candidate range must be available, including dates outside the visible month. Bounds and availability constrain focus/activation independently of the supplied value; an invalid externally supplied range is retained and described, never silently corrected through a callback.

Rebuild when availability changes, including changes to data captured by the same function object. Completion also rechecks the current predicate. Invalid completion dates remain keyboard-discoverable but do not expose a selection action. An invalid anchor is discarded without changing the committed range.

Every widget update revalidates the committed range with O(span) predicate calls, even if the function identity is unchanged. Completion also rechecks the whole candidate interval. Between rebuilds, availability results are cached, so focus moves and browsing check only newly shown dates; rebuild the calendar when the predicate's results change. Use a cheap synchronous predicate; network fetching and business validation belong to the application, not the calendar.

Presentation and localization

Calendar labels default explicitly to English. Use RemixCalendarLabels for date formatting, navigation and weekday names; range-specific labels belong to RemixRangeCalendarLabels. Directionality controls RTL layout and horizontal keys; it does not translate text. firstDayOfWeek uses Monday=1 through Sunday=7.

Committed interval dates retain selected semantics. A draft is described separately; it is not a second committed selection. Calendar days retain their behavior-owning semantics even when their visuals are styled.

A range summary (the selected range, the next instruction and any availability problem) is announced as a live region. It is not painted by default; pass summaryBuilder to show it, for example (context, summary) => Text(summary). RemixRangeCalendarLabels.joinSummary and RemixCalendarLabels.joinDayLabel join the summary's sentences and each day's name parts, so their punctuation is localizable.

Styling the interval

Use the same CalendarStyler and independently resolved navigation/selector styles as Calendar. Range days are styled with day variants on CalendarStyler.day, resolved in each day's own state:

VariantApplies to
onRangeStartFirst day of a multi-day painted interval.
onRangeMiddleDays strictly between the first and last day.
onRangeEndLast day of a multi-day painted interval.
onRangeSingleA one-day interval, instead of combining start/end caps.
onRangePreviewEvery date in the local draft interval.

The painted interval is the draft while anchored, otherwise the committed value. Range variants rank with widget states: Mix applies them after plain context variants, and among widget-state variants the one declared last wins on shared properties. Declare them after onSelected (and onDisabled) to override those, and declare position variants after onRangePreview so caps sit on the draft fill. Selected semantics continue to describe the committed range, including while a draft overlaps it.

Use directional corner radii for chronological endpoint caps. Keep merged radius values in the same coordinate family with Mix 2.3.1: do not combine a physical base radius (.circular(...)) with directional endpoint radii. Leave the base radius unset for square interiors, as below.

import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';

final endpoint = CalendarDayStyler()
    .color(const Color(0xFF3E63DD))
    .label(TextStyler().color(const Color(0xFFFFFFFF)));

final rangeStyle = CalendarStyler().day(
  CalendarDayStyler()
      .onSelected(
        CalendarDayStyler()
            .color(const Color(0xFFDBE5FF))
            .label(TextStyler().color(const Color(0xFF18306A))),
      )
      .onRangePreview(
        CalendarDayStyler()
            .color(const Color(0xFFFAE2A0))
            .label(TextStyler().color(const Color(0xFF653D00))),
      )
      .onRangeStart(endpoint.borderRadius(.only(
        topStart: const Radius.circular(8),
        bottomStart: const Radius.circular(8),
      )))
      .onRangeEnd(endpoint.borderRadius(.only(
        topEnd: const Radius.circular(8),
        bottomEnd: const Radius.circular(8),
      )))
      .onRangeSingle(endpoint.borderRadius(.circular(8))),
);

Pass this to RemixRangeCalendar(style: rangeStyle, ...); CalendarStyler.call builds a single-date RemixCalendar. Variants keep working through root animations, because each frame builds the nested day style in that day's state. A raw styleSpec is already resolved and carries no variants: every day renders its day spec, so selection, availability and range positions are not painted. Use style for state and range styling.

Interaction and scope

The keyboard contract matches Calendar: arrows move by day/week, Home/End move within the week, PageUp/PageDown move by month, and Shift with PageUp/PageDown moves by year. Enter, Space, semantic activation and pointer release use the same two-step selection contract. Hover, pointer-down, cancellation and scrolling do not select. Touch has no hover or drag prerequisite.

Default day targets are at least 44 logical pixels; narrow layouts and enlarged text scroll without shrinking them. Header replacement remains presentation-only: use the snapshot's bounded navigation actions and keep day behavior owned by the calendar.

This component shows one month at a time and supports cross-month ranges through navigation. Typed/segmented entry, multiple visible months, drag selection, partial committed values, Apply/Cancel modes, min/max nights, non-Gregorian calendars and timezone scheduling are outside this API. The Vanilla recipe above packages the range styles for installation.

Vanilla recipe

In a Vanilla app, install range_calendar and import your generated UI barrel:

remix add range_calendar
import 'package:flutter/widgets.dart';
import 'ui/ui.dart';

Widget example() => UiRangeCalendar(
  value: null,
  firstDate: DateTime(2026),
  lastDate: DateTime(2028, 12, 31),
  onChanged: (value) { /* Store the requested value in application state. */ },
);

Ui is the application's chosen prefix. Wrap the host Navigator in UiTheme. The hand-written facade supplies theme typography, Vanilla icon navigation and Select selectors that grow with text. Day treatments cover today, selection, hover, press, keyboard focus and disabled dates; unavailable dates add a strike through, while out-of-bounds dates only fade. Override style, navigationStyle and selectorStyle last. CalendarSpec has no generator target, so this composite deliberately has no generated part.

Committed endpoints use primary and interiors use accent. The draft uses a tinted fill and primary outline; the prior committed range becomes muted until the draft ends. Unavailable draft days use destructive text.

Constructor

Constructor

import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';

RemixRangeCalendar remixRangeCalendarConstructor({
  Key? key,
  required CivilDateRange? value,
  required DateTime firstDate,
  required DateTime lastDate,
  ValueChanged<CivilDateRange>? onChanged,
  DateTime? currentDate,
  bool Function(DateTime)? isDateUnavailable,
  int firstDayOfWeek = DateTime.sunday,
  bool enabled = true,
  String? semanticLabel,
  FocusNode? focusNode,
  bool autofocus = false,
  RemixCalendarLabels labels = const RemixCalendarLabels(),
  RemixRangeCalendarLabels rangeLabels = const RemixRangeCalendarLabels(),
  Widget Function(BuildContext, NakedRangeCalendarState)? headerBuilder,
  IconButtonStyler navigationStyle = const IconButtonStyler.create(),
  SelectStyler selectorStyle = const SelectStyler.create(),
  CalendarStyler style = const CalendarStyler.create(),
  CalendarSpec? styleSpec,
  Widget Function(BuildContext, String)? summaryBuilder,
}) => throw UnimplementedError();

Fortal widgets

In a Fortal app, remix add range_calendar installs <Prefix>RangeCalendar through the same UI barrel. It retains the Remix date, labels, focus and controlled selection contract above. The hand-written facade merges caller child styles last over the reviewed Fortal calendar, ghost navigation and Select recipes. Day targets and chevrons scale with text. Today uses bold gray12 ink; range drafts have a separate perimeter and a quieter previous selection. Nested month/year panels have a bounded height and a persistent scroll thumb. See Fortal for theme setup.

Properties

Widget Properties

value → CivilDateRange?

Required. Parent-owned complete inclusive range, compared by civil fields. Null shows no selection. Invalid values remain selected without correction.

firstDate / lastDate → DateTime

Required. Inclusive bounds; times and UTC flags are ignored. firstDate must be on or before lastDate.

onChanged → ValueChanged<CivilDateRange>?

Optional. Requests one complete range. The parent accepts it by updating value. Null disables interaction.

currentDate → DateTime?

Optional. Today, for onToday and fallback focus. Defaults to now.

isDateUnavailable → bool Function(DateTime)?

Optional. Marks dates unavailable, including offscreen interior dates. See Unavailable dates for validation cost and rebuild rules.

firstDayOfWeek → int

Optional. First weekday column, Monday=1 through Sunday=7. Defaults to Sunday.

enabled → bool

Optional. Enables navigation, focus and selection when onChanged is non-null.

semanticLabel → String?

Optional. Accessible name of the calendar group.

focusNode → FocusNode?

Optional. Focus entry point; requesting it focuses the current day.

autofocus → bool

Optional. Whether to focus the current day on mount. Defaults to false.

labels → RemixCalendarLabels

Optional. Caller-owned date labels and formatters; English by default.

rangeLabels → RemixRangeCalendarLabels

Optional. Caller-owned range roles, prompts, selected-range formatter and joiners; English by default. Disabled calendars retain the selected-range description without selection prompts.

headerBuilder → Widget Function(BuildContext, NakedRangeCalendarState)?

Optional. Replaces header visuals and navigation using the snapshot's bounded actions. Day selection, focus and semantics remain owned by the calendar.

summaryBuilder → Widget Function(BuildContext, String)?

Optional. Paints the localized summary and retains its live-region semantics. By default the summary is unpainted over the weekday row.

Optional. Previous/next button styling, resolved in each button's state.

selectorStyle → SelectStyler

Optional. Independent month/year selector styling.

style → CalendarStyler

Optional. Calendar and day styling, including range variants.

styleSpec → CalendarSpec?

Optional. Raw resolved spec that bypasses style and inherited styles. It carries no variants, so all days render the same day spec without range or state paint.

Style Methods

Use the shared Calendar style methods for container, weekday, day and selector spacing. Range-only day variants are onRangeStart, onRangeMiddle, onRangeEnd, onRangeSingle and onRangePreview; see Styling the interval for their ordering and painted interval.

HeroUI widgets

In a HeroUI app, remix add range_calendar installs <Prefix>RangeCalendar with the same controlled value, bounds, availability callbacks and labels as Remix. The hand-written composition merges caller styles last for each independent part.

The recipes port HeroUI web 3.2.6 colors, typography and day geometry. Month/year Select menus replace the upstream year grid. Date pickers use a field-shaped button in place of segmented text entry. Day tiles and icons grow with text; range drafts take visual precedence over the previous committed range. Picker panels retain equal 12px insets. See Open Code for theme setup.

View page source on GitHub