ashlarui

Calendar

Months of days to pick one, several or a range from, with week numbers and dropdowns.

Calendar, as it looks in an app Calendar, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/calendar.dart';
import 'ui/theme.dart';

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

  @override
  State<CalendarDemo> createState() => _CalendarDemoState();
}

class _CalendarDemoState extends State<CalendarDemo> {
  DateTime? day = DateTime(2026, 10, 13);
  DateRange? stay = DateRange(DateTime(2026, 10, 20), DateTime(2026, 10, 24));

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    BoxDecoration frame() => BoxDecoration(
      color: t.colors.card,
      border: Border.all(color: t.colors.border),
      borderRadius: BorderRadius.circular(t.radiusLg),
      boxShadow: t.shadowSm,
    );
    return Row(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Container(
          padding: const EdgeInsets.all(12),
          decoration: frame(),
          child: Calendar(selected: day, onSelected: (d) => setState(() => day = d)),
        ),
        const SizedBox(width: 24),
        Container(
          padding: const EdgeInsets.all(12),
          decoration: frame(),
          child: Calendar.range(
            range: stay,
            isDisabled: (d) => d.weekday == DateTime.sunday,
            onRangeChanged: (r) => setState(() => stay = r),
          ),
        ),
      ],
    );
  }
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Calendar with controls, as it looks in an app Calendar with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add calendar

It writes theme.dart, palette.dart, group.dart, pressable.dart, button.dart, popover.dart, select.dart and calendar.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs button, select and ashlar_icons, which come with it.

Examples

Other ways to use it, each running live, with its code.

A span of days

Two months, a start and an end.

A span of days, as it looks in an app A span of days, as it looks in an app, dark
// A span of days: Two months, a start and an end.
import 'package:flutter/widgets.dart';

import 'ui/calendar.dart';

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

  @override
  State<CalendarRangeExample> createState() => _CalendarRangeExampleState();
}

class _CalendarRangeExampleState extends State<CalendarRangeExample> {
  DateRange? range = DateRange(DateTime(2026, 10, 12), DateTime(2026, 10, 20));

  @override
  Widget build(BuildContext context) => Calendar.range(
    months: 2,
    range: range,
    initialMonth: DateTime(2026, 10),
    onRangeChanged: (r) => setState(() => range = r),
  );
}

Several days

Pick any days.

Several days, as it looks in an app Several days, as it looks in an app, dark
// Several days: Pick any days.
import 'package:flutter/widgets.dart';

import 'ui/calendar.dart';

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

  @override
  State<CalendarMultipleExample> createState() => _CalendarMultipleExampleState();
}

class _CalendarMultipleExampleState extends State<CalendarMultipleExample> {
  var days = {DateTime(2026, 10, 6), DateTime(2026, 10, 13), DateTime(2026, 10, 20)};

  @override
  Widget build(BuildContext context) => Calendar.multiple(
    days: days,
    initialMonth: DateTime(2026, 10),
    onDaysChanged: (d) => setState(() => days = d),
  );
}

Week numbers and dropdowns

Jump by month and year; the ISO week beside each row.

Week numbers and dropdowns, as it looks in an app Week numbers and dropdowns, as it looks in an app, dark
// Week numbers and dropdowns: Jump by month and year; the ISO week beside each row.
import 'package:flutter/widgets.dart';

import 'ui/calendar.dart';

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

  @override
  State<CalendarWeeksExample> createState() => _CalendarWeeksExampleState();
}

class _CalendarWeeksExampleState extends State<CalendarWeeksExample> {
  DateTime? day = DateTime(2026, 10, 13);

  @override
  Widget build(BuildContext context) => Calendar(
    selected: day,
    initialMonth: DateTime(2026, 10),
    showWeekNumbers: true,
    dropdowns: true,
    onSelected: (d) => setState(() => day = d),
  );
}

Days that cannot be picked

Weekends closed.

Days that cannot be picked, as it looks in an app Days that cannot be picked, as it looks in an app, dark
// Days that cannot be picked: Weekends closed.
import 'package:flutter/widgets.dart';

import 'ui/calendar.dart';

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

  @override
  State<CalendarBookedExample> createState() => _CalendarBookedExampleState();
}

class _CalendarBookedExampleState extends State<CalendarBookedExample> {
  DateTime? day;

  @override
  Widget build(BuildContext context) => Calendar(
    selected: day,
    initialMonth: DateTime(2026, 10),
    isDisabled: (d) => d.weekday >= DateTime.saturday,
    onSelected: (d) => setState(() => day = d),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

DateNamesclass

The words dates are written in: month and weekday names, which day a week starts on, and how a date is written out. English unless an app puts others above it with DateNamesScope, for another language or another week.

Parameter Type Default What it is for
monthsrequired List<String> January to December.
weekdaysrequired List<String> Monday to Sunday, in full, for screen readers.
weekdaysShortrequired List<String> Monday to Sunday, as short as a column heading takes.
firstWeekday int DateTime.monday DateTime.monday to DateTime.sunday.
format String Function(DateNames names, DateTime date) _dayMonthYear A date written out: "13 October 2026".

DateNamesScopeclass

Puts names above a part of the app, or the whole of it.

Parameter Type Default What it is for
namesrequired DateNames Month and weekday names, and how a date is written.
childrequired The part of the app below it, which it applies to.

DateRangeclass

From start to end, both days included. While a range is being chosen, end is null.

Parameter Type Default What it is for
startpositional DateTime The first day.
endpositional, optional DateTime? The last day, or null while only the first is picked.

isoWeekfunction

The ISO week of the year a day falls in: weeks start on Monday, and week 1 holds the year's first Thursday.

Parameter Type Default What it is for
dpositional DateTime The day.

Calendarclass

Months of days to pick from: one day, several with Calendar.multiple, or a start and an end with Calendar.range. The arrows either side change the month; the month's name opens the months of its year, and that year opens a choice of years. With dropdowns the month and year are picked from lists instead. From the keyboard the arrow keys move a day or a week, Page Up and Page Down a month, Home and End to the week's ends, and Enter or Space picks.

Calendar(…)

Parameter Type Default What it is for
selectedrequired DateTime? The day picked, or null.
onSelectedrequired ValueChanged<DateTime> Told the day picked.
initialMonth DateTime? The month shown first: the selected one, else this month.
firstDate DateTime? The earliest and latest days that can be picked.
lastDate DateTime? The earliest and latest days that can be picked.
isDisabled bool Function(DateTime day)? Days that cannot be picked: weekends, booked days.
autofocus bool false Takes the keyboard's focus when it first appears, on the picked day.
months int 1 How many months show side by side.
showWeekNumbers bool false The week of the year before each row.
dropdowns bool false The month and year picked from lists, for going far quickly: a date of birth.

Calendar.range(…)

Parameter Type Default What it is for
rangerequired DateRange? The days picked, for a calendar that takes a span.
onRangeChangedrequired ValueChanged<DateRange> Told the new range whenever either end changes.
initialMonth DateTime? The month shown first: the selected one, else this month.
firstDate DateTime? The earliest and latest days that can be picked.
lastDate DateTime? The earliest and latest days that can be picked.
isDisabled bool Function(DateTime day)? Days that cannot be picked: weekends, booked days.
autofocus bool false Takes the keyboard's focus when it first appears, on the picked day.
months int 1 How many months show side by side.
showWeekNumbers bool false The week of the year before each row.
dropdowns bool false The month and year picked from lists, for going far quickly: a date of birth.

Calendar.multiple(…)

Parameter Type Default What it is for
daysrequired Set<DateTime> The days picked, each at midnight.
onDaysChangedrequired ValueChanged<Set<DateTime>> Told the days picked, for a calendar that takes several.
initialMonth DateTime? The month shown first: the selected one, else this month.
firstDate DateTime? The earliest and latest days that can be picked.
lastDate DateTime? The earliest and latest days that can be picked.
isDisabled bool Function(DateTime day)? Days that cannot be picked: weekends, booked days.
autofocus bool false Takes the keyboard's focus when it first appears, on the picked day.
months int 1 How many months show side by side.
showWeekNumbers bool false The week of the year before each row.
dropdowns bool false The month and year picked from lists, for going far quickly: a date of birth.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/calendar.dart 913 lines
// Ashlar UI: Calendar. Copied into your app by `ashlar add calendar`; it is yours to change.

import 'dart:math' as math;

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'button.dart';
import 'select.dart';
import 'theme.dart';

/// The words dates are written in: month and weekday names, which day a week starts on,
/// and how a date is written out. English unless an app puts others above it with
/// [DateNamesScope], for another language or another week.
@immutable
class DateNames {
  const DateNames({
    required this.months,
    required this.weekdays,
    required this.weekdaysShort,
    this.firstWeekday = DateTime.monday,
    this.format = _dayMonthYear,
  });

  /// January to December.
  final List<String> months;

  /// Monday to Sunday, in full, for screen readers.
  final List<String> weekdays;

  /// Monday to Sunday, as short as a column heading takes.
  final List<String> weekdaysShort;

  /// [DateTime.monday] to [DateTime.sunday].
  final int firstWeekday;

  /// A date written out: "13 October 2026".
  final String Function(DateNames names, DateTime date) format;

  static String _dayMonthYear(DateNames n, DateTime d) =>
      '${d.day} ${n.months[d.month - 1]} ${d.year}';

  static const english = DateNames(
    months: [
      'January',
      'February',
      'March',
      'April',
      'May',
      'June',
      'July',
      'August',
      'September',
      'October',
      'November',
      'December',
    ],
    weekdays: [
      'Monday',
      'Tuesday',
      'Wednesday',
      'Thursday',
      'Friday',
      'Saturday',
      'Sunday',
    ],
    weekdaysShort: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
  );

  /// The names above [context], or English.
  static DateNames of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<DateNamesScope>()?.names ??
      english;

  String date(DateTime d) => format(this, d);
  String monthYear(DateTime d) => '${months[d.month - 1]} ${d.year}';
}

/// Puts [names] above a part of the app, or the whole of it.
class DateNamesScope extends InheritedWidget {
  const DateNamesScope({super.key, required this.names, required super.child});

  /// Month and weekday names, and how a date is written.
  final DateNames names;

  @override
  bool updateShouldNotify(DateNamesScope old) => old.names != names;
}

/// From [start] to [end], both days included. While a range is being chosen, [end] is null.
@immutable
class DateRange {
  const DateRange(this.start, [this.end]);

  /// The first day.
  final DateTime start;

  /// The last day, or null while only the first is picked.
  final DateTime? end;

  bool contains(DateTime d) =>
      end != null && !d.isBefore(_day(start)) && !d.isAfter(_day(end!));

  @override
  bool operator ==(Object other) =>
      other is DateRange && other.start == start && other.end == end;

  @override
  int get hashCode => Object.hash(start, end);
}

DateTime _day(DateTime d) => DateTime(d.year, d.month, d.day);
bool _same(DateTime? a, DateTime? b) =>
    a != null &&
    b != null &&
    a.year == b.year &&
    a.month == b.month &&
    a.day == b.day;

/// The ISO week of the year a day falls in: weeks start on Monday, and week 1 holds the
/// year's first Thursday.
int isoWeek(DateTime d) {
  final day = DateTime.utc(d.year, d.month, d.day);
  final thursday = day.add(Duration(days: 4 - day.weekday));
  final first = DateTime.utc(thursday.year, 1, 1);
  return thursday.difference(first).inDays ~/ 7 + 1;
}

/// Months of days to pick from: one day, several with [Calendar.multiple], or a start and
/// an end with [Calendar.range]. The arrows either side change the month; the month's
/// name opens the months of its year, and that year opens a choice of years. With
/// [dropdowns] the month and year are picked from lists instead. From the keyboard the arrow keys
/// move a day or a week, Page Up and Page Down a month, Home and End to the week's ends,
/// and Enter or Space picks.
///
/// ```dart
/// Calendar(selected: day, onSelected: (d) => setState(() => day = d))
/// Calendar.range(range: stay, months: 2, onRangeChanged: (r) => setState(() => stay = r))
/// Calendar.multiple(days: picked, onDaysChanged: (d) => setState(() => picked = d))
/// ```
class Calendar extends StatefulWidget {
  const Calendar({
    super.key,
    required this.selected,
    required ValueChanged<DateTime> this.onSelected,
    this.initialMonth,
    this.firstDate,
    this.lastDate,
    this.isDisabled,
    this.autofocus = false,
    this.months = 1,
    this.showWeekNumbers = false,
    this.dropdowns = false,
  }) : range = null,
       onRangeChanged = null,
       days = null,
       onDaysChanged = null;

  const Calendar.range({
    super.key,
    required this.range,
    required ValueChanged<DateRange> this.onRangeChanged,
    this.initialMonth,
    this.firstDate,
    this.lastDate,
    this.isDisabled,
    this.autofocus = false,
    this.months = 1,
    this.showWeekNumbers = false,
    this.dropdowns = false,
  }) : selected = null,
       onSelected = null,
       days = null,
       onDaysChanged = null;

  const Calendar.multiple({
    super.key,
    required Set<DateTime> this.days,
    required ValueChanged<Set<DateTime>> this.onDaysChanged,
    this.initialMonth,
    this.firstDate,
    this.lastDate,
    this.isDisabled,
    this.autofocus = false,
    this.months = 1,
    this.showWeekNumbers = false,
    this.dropdowns = false,
  }) : selected = null,
       onSelected = null,
       range = null,
       onRangeChanged = null;

  /// The day picked, or null.
  final DateTime? selected;

  /// Told the day picked.
  final ValueChanged<DateTime>? onSelected;

  /// The days picked, for a calendar that takes a span.
  final DateRange? range;

  /// Told the new range whenever either end changes.
  final ValueChanged<DateRange>? onRangeChanged;

  /// The days picked, each at midnight.
  final Set<DateTime>? days;

  /// Told the days picked, for a calendar that takes several.
  final ValueChanged<Set<DateTime>>? onDaysChanged;

  /// The month shown first: the selected one, else this month.
  final DateTime? initialMonth;

  /// The earliest and latest days that can be picked.
  final DateTime? firstDate, lastDate;

  /// Days that cannot be picked: weekends, booked days.
  final bool Function(DateTime day)? isDisabled;

  /// Takes the keyboard's focus when it first appears, on the picked day.
  final bool autofocus;

  /// How many months show side by side.
  final int months;

  /// The week of the year before each row.
  final bool showWeekNumbers;

  /// The month and year picked from lists, for going far quickly: a date of birth.
  final bool dropdowns;

  @override
  State<Calendar> createState() => _CalendarState();
}

class _CalendarState extends State<Calendar> {
  late DateTime _month = _monthOf(
    widget.initialMonth ??
        widget.selected ??
        widget.range?.start ??
        widget.days?.firstOrNull ??
        DateTime.now(),
  );
  late DateTime _focus = _day(
    widget.selected ??
        widget.range?.start ??
        widget.days?.firstOrNull ??
        widget.initialMonth ??
        DateTime.now(),
  );
  final _node = FocusNode(debugLabel: 'Calendar');

  /// A day's square and the weekdays' row, grown with large text so a number is never
  /// cut off: 32 and 24 at its usual size.
  double _cell = 32, _head = 24, _header = 32;

  void _measure(BuildContext context) {
    final scaler = MediaQuery.textScalerOf(context);
    _cell = math.max(32, scaler.scale(20));
    _head = math.max(24, scaler.scale(16) + 8);
    _header = grownHeight(context, 32);
  }

  bool _keyboard = false;

  /// Focus the calendar gives itself (as it opens, or as its view changes) shows no ring:
  /// a click brought it there. Focus reached with the keys does.
  bool _quiet = false;

  void _focusQuietly() {
    _quiet = true;
    _node.requestFocus();
    WidgetsBinding.instance.addPostFrameCallback((_) => _quiet = false);
  }

  /// Days, or the months of [_year], or the twelve years from [_years].
  _View _view = _View.days;
  int _year = 0, _years = 0;

  void _open(_View view, int year) => setState(() {
    // The button pressed goes with the view: the calendar keeps the keyboard's focus.
    _focusQuietly();
    _view = view;
    _year = year;
    // The twelve years shown hold the year, a dozen to a page.
    _years = year - (year % 12);
  });

  static DateTime _monthOf(DateTime d) => DateTime(d.year, d.month);

  @override
  void initState() {
    super.initState();
    // In a menu's panel (a date picker's) the menu takes focus itself; the calendar then
    // takes it, so the first arrow pressed moves through the days.
    if (widget.autofocus) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (mounted) _focusQuietly();
      });
    }
  }

  @override
  void dispose() {
    _node.dispose();
    super.dispose();
  }

  bool _off(DateTime d) =>
      (widget.firstDate != null && d.isBefore(_day(widget.firstDate!))) ||
      (widget.lastDate != null && d.isAfter(_day(widget.lastDate!))) ||
      (widget.isDisabled?.call(d) ?? false);

  void _pick(DateTime d) {
    if (_off(d)) return;
    setState(() => _focus = d);
    if (widget.onSelected != null) {
      widget.onSelected!(d);
      return;
    }
    if (widget.onDaysChanged != null) {
      final next = {...widget.days!};
      final had = next.where((x) => _same(x, d)).toList();
      had.isEmpty ? next.add(d) : next.removeAll(had);
      widget.onDaysChanged!(next);
      return;
    }
    final r = widget.range;
    // A finished range, or none: start again. A start only: this is the other end.
    if (r == null || r.end != null) {
      widget.onRangeChanged!(DateRange(d));
    } else {
      widget.onRangeChanged!(
        d.isBefore(r.start) ? DateRange(d, r.start) : DateRange(r.start, d),
      );
    }
  }

  void _showMonth(int by) =>
      setState(() => _month = DateTime(_month.year, _month.month + by));

  void _moveFocus(DateTime to) {
    setState(() {
      _focus = to;
      // Keep the focused day in view: move the months only when it leaves them.
      final last = DateTime(_month.year, _month.month + widget.months - 1);
      if (to.isBefore(_month)) _month = _monthOf(to);
      if (to.isAfter(DateTime(last.year, last.month + 1, 0))) {
        _month = DateTime(to.year, to.month - widget.months + 1);
      }
    });
  }

  KeyEventResult _key(FocusNode node, KeyEvent e, int first) {
    if (e is! KeyDownEvent && e is! KeyRepeatEvent) {
      return KeyEventResult.ignored;
    }
    final k = e.logicalKey;
    final f = _focus;
    final DateTime? to = switch (k) {
      LogicalKeyboardKey.arrowLeft => DateTime(
        f.year,
        f.month,
        f.day - (isRtl(context) ? -1 : 1),
      ),
      LogicalKeyboardKey.arrowRight => DateTime(
        f.year,
        f.month,
        f.day + (isRtl(context) ? -1 : 1),
      ),
      LogicalKeyboardKey.arrowUp => DateTime(f.year, f.month, f.day - 7),
      LogicalKeyboardKey.arrowDown => DateTime(f.year, f.month, f.day + 7),
      LogicalKeyboardKey.pageUp => DateTime(f.year, f.month - 1, f.day),
      LogicalKeyboardKey.pageDown => DateTime(f.year, f.month + 1, f.day),
      LogicalKeyboardKey.home => DateTime(
        f.year,
        f.month,
        f.day - (f.weekday - first + 7) % 7,
      ),
      LogicalKeyboardKey.end => DateTime(
        f.year,
        f.month,
        f.day + 6 - (f.weekday - first + 7) % 7,
      ),
      _ => null,
    };
    if (to != null) {
      setState(() => _keyboard = true);
      _moveFocus(_day(to));
      return KeyEventResult.handled;
    }
    if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.space) {
      _pick(_focus);
      return KeyEventResult.handled;
    }
    return KeyEventResult.ignored;
  }

  @override
  Widget build(BuildContext context) {
    _measure(context);
    if (_view != _View.days) return _picker(context);
    final names = DateNames.of(context);
    final canBack =
        widget.firstDate == null || _month.isAfter(_monthOf(widget.firstDate!));
    final lastShown = DateTime(_month.year, _month.month + widget.months - 1);
    final canOn =
        widget.lastDate == null ||
        lastShown.isBefore(_monthOf(widget.lastDate!));
    return Focus(
      focusNode: _node,
      onFocusChange: (f) => setState(
        () => _keyboard =
            f &&
            !_quiet &&
            FocusManager.instance.highlightMode ==
                FocusHighlightMode.traditional,
      ),
      onKeyEvent: (node, e) => _key(node, e, names.firstWeekday),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (var m = 0; m < widget.months; m++) ...[
            if (m > 0) const SizedBox(width: 16),
            _grid(
              context,
              DateTime(_month.year, _month.month + m),
              back: m == 0 ? (canBack ? () => _showMonth(-1) : null) : null,
              on: m == widget.months - 1
                  ? (canOn ? () => _showMonth(1) : null)
                  : null,
              first: m == 0,
              last: m == widget.months - 1,
            ),
          ],
        ],
      ),
    );
  }

  Widget _caption(BuildContext context, DateTime month) {
    final t = AshlarTheme.of(context);
    final names = DateNames.of(context);
    if (!widget.dropdowns) {
      // The month's name opens its year's months.
      return Button(
        variant: ButtonVariant.ghost,
        size: ButtonSize.sm,
        semanticLabel: '${names.monthYear(month)}, choose a month and year',
        onPressed: () => _open(_View.months, month.year),
        child: Text(
          names.monthYear(month),
          style: t.text.sm.copyWith(
            fontWeight: FontWeight.w500,
            color: t.colors.foreground,
          ),
        ),
      );
    }
    final from = widget.firstDate?.year ?? month.year - 100,
        to = widget.lastDate?.year ?? month.year + 10;
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Expanded(
          child: Select<int>(
            size: SelectSize.sm,
            value: month.month,
            semanticLabel: 'Month',
            options: [
              for (var i = 1; i <= 12; i++)
                SelectOption(i, names.months[i - 1].substring(0, 3)),
            ],
            onChanged: (v) => setState(() => _month = DateTime(_month.year, v)),
          ),
        ),
        const SizedBox(width: 4),
        Expanded(
          child: Select<int>(
            size: SelectSize.sm,
            value: month.year,
            semanticLabel: 'Year',
            options: [for (var y = from; y <= to; y++) SelectOption(y, '$y')],
            onChanged: (v) =>
                setState(() => _month = DateTime(v, _month.month)),
          ),
        ),
      ],
    );
  }

  /// The months of a year, or a dozen years, in the room the days take, so a popover
  /// keeps its size as the view changes.
  Widget _picker(BuildContext context) {
    final t = AshlarTheme.of(context);
    final names = DateNames.of(context);
    final months = _view == _View.months;
    final lead = (_month.weekday - names.firstWeekday + 7) % 7;
    final count = DateTime(_month.year, _month.month + 1, 0).day;
    final weeks = ((lead + count) / 7).ceil();
    final width =
        widget.months * ((widget.showWeekNumbers ? _cell : 0.0) + 7 * _cell) +
        (widget.months - 1) * 16.0;
    final height = _header + 8 + _head + 4 + weeks * (_cell + 2);
    final first = widget.firstDate, last = widget.lastDate;
    // A month or a year can be chosen when some of it lies between the first and last dates.
    bool monthOff(int m) =>
        (first != null &&
            DateTime(
              _year,
              m + 1,
            ).isBefore(DateTime(first.year, first.month, 2))) ||
        (last != null && DateTime(_year, m).isAfter(last));
    bool yearOff(int y) =>
        (first != null && y < first.year) || (last != null && y > last.year);
    final VoidCallback? back = months
        ? (first == null || _year > first.year
              ? () => setState(() => _year--)
              : null)
        : (first == null || _years > first.year
              ? () => setState(() => _years -= 12)
              : null);
    final VoidCallback? on = months
        ? (last == null || _year < last.year
              ? () => setState(() => _year++)
              : null)
        : (last == null || _years + 11 < last.year
              ? () => setState(() => _years += 12)
              : null);
    Widget cell(
      String label,
      String spoken,
      bool chosen,
      bool off,
      VoidCallback pick,
    ) => Padding(
      padding: const EdgeInsets.all(3),
      child: Button(
        variant: chosen ? ButtonVariant.primary : ButtonVariant.ghost,
        size: ButtonSize.sm,
        semanticLabel: spoken,
        onPressed: off ? null : pick,
        child: Text(label),
      ),
    );
    return Focus(
      focusNode: _node,
      // Escape steps back towards the days, rather than closing what holds the calendar.
      onKeyEvent: (node, e) {
        if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) {
          setState(() => _view = months ? _View.days : _View.months);
          return KeyEventResult.handled;
        }
        return KeyEventResult.ignored;
      },
      child: SizedBox(
        width: width,
        height: height,
        child: Column(
          children: [
            SizedBox(
              // Its own height, taller with large text: a finger's room around its
              // buttons spills over rather than growing it.
              height: _header,
              child: Row(
                children: [
                  _Nav(
                    icon: isRtl(context)
                        ? AshlarIconsRoundedStroke.chevronRight
                        : AshlarIconsRoundedStroke.chevronLeft,
                    label: months ? 'Previous year' : 'Earlier years',
                    onPressed: back,
                  ),
                  Expanded(
                    child: Center(
                      child: months
                          // The year opens a choice of years.
                          ? Button(
                              variant: ButtonVariant.ghost,
                              size: ButtonSize.sm,
                              semanticLabel: '$_year, choose a year',
                              onPressed: () => _open(_View.years, _year),
                              child: Text('$_year'),
                            )
                          : Text(
                              '$_years to ${_years + 11}',
                              style: t.text.sm.copyWith(
                                fontWeight: FontWeight.w500,
                              ),
                            ),
                    ),
                  ),
                  _Nav(
                    icon: isRtl(context)
                        ? AshlarIconsRoundedStroke.chevronLeft
                        : AshlarIconsRoundedStroke.chevronRight,
                    label: months ? 'Next year' : 'Later years',
                    onPressed: on,
                  ),
                ],
              ),
            ),
            const SizedBox(height: 8),
            Expanded(
              child: Column(
                children: [
                  for (var row = 0; row < 4; row++)
                    Expanded(
                      child: Row(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: [
                          for (var col = 0; col < 3; col++)
                            Expanded(
                              child: months
                                  ? cell(
                                      names.months[row * 3 + col].substring(
                                        0,
                                        3,
                                      ),
                                      '${names.months[row * 3 + col]} $_year',
                                      _month.year == _year &&
                                          _month.month == row * 3 + col + 1,
                                      monthOff(row * 3 + col + 1),
                                      () => setState(() {
                                        _focusQuietly();
                                        _month = DateTime(
                                          _year,
                                          row * 3 + col + 1,
                                        );
                                        _view = _View.days;
                                      }),
                                    )
                                  : cell(
                                      '${_years + row * 3 + col}',
                                      '${_years + row * 3 + col}',
                                      _year == _years + row * 3 + col,
                                      yearOff(_years + row * 3 + col),
                                      () => _open(
                                        _View.months,
                                        _years + row * 3 + col,
                                      ),
                                    ),
                            ),
                        ],
                      ),
                    ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _grid(
    BuildContext context,
    DateTime month, {
    VoidCallback? back,
    VoidCallback? on,
    required bool first,
    required bool last,
  }) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final names = DateNames.of(context);
    final firstDay = names.firstWeekday;
    // The grid starts on the first weekday on or before the 1st, and runs whole weeks.
    final lead = (month.weekday - firstDay + 7) % 7;
    final start = DateTime(month.year, month.month, 1 - lead);
    final count = DateTime(month.year, month.month + 1, 0).day;
    final weeks = ((lead + count) / 7).ceil();
    final today = _day(DateTime.now());
    final weekCol = widget.showWeekNumbers ? _cell : 0.0;

    Widget day(DateTime d) {
      final outside = d.month != month.month;
      // With two months, a day outside its month is left to the month it belongs to.
      if (outside && widget.months > 1) return const SizedBox();
      final off = _off(d);
      final r = widget.range;
      final isStart =
          _same(d, r?.start) ||
          _same(d, widget.selected) ||
          (widget.days?.any((x) => _same(x, d)) ?? false);
      final isEnd = _same(d, r?.end);
      final middle = !isStart && !isEnd && (r?.contains(d) ?? false);
      final picked = isStart || isEnd;
      final focused = _keyboard && _node.hasFocus && _same(d, _focus);
      final isToday = _same(d, today);
      final fg = picked
          ? c.primaryForeground
          : (middle
                ? c.accentForeground
                : (outside ? c.mutedForeground : c.foreground));
      // A range is one band: the ends round only on their outer side.
      final radius = Radius.circular(t.radiusMd);
      final inRange = r?.end != null && !_same(r!.start, r.end);
      final band = BorderRadiusDirectional.horizontal(
        start: middle || (isEnd && inRange) ? Radius.zero : radius,
        end: middle || (isStart && inRange && r.end != null)
            ? Radius.zero
            : radius,
      );
      return Semantics(
        button: true,
        enabled: !off,
        selected: picked || middle,
        label:
            '${names.weekdays[d.weekday - 1]}, ${names.date(d)}${isToday ? ', today' : ''}',
        onTap: off ? null : () => _pick(d),
        child: ExcludeSemantics(
          child: _Day(
            onTap: off ? null : () => _pick(d),
            builder: (hovered) => Container(
              decoration: BoxDecoration(
                color: picked
                    ? c.primary
                    : (middle
                          ? c.accent
                          : ((hovered || isToday) && !off
                                ? c.accent
                                : c.accent.withValues(alpha: 0))),
                borderRadius: middle || picked
                    ? band
                    : BorderRadius.all(radius),
                boxShadow: [if (focused) ...t.focusRing],
              ),
              alignment: Alignment.center,
              child: Opacity(
                opacity: off ? .5 : 1,
                child: Text(
                  '${d.day}',
                  style: t.text.sm.copyWith(
                    color: fg,
                    decoration: off ? TextDecoration.lineThrough : null,
                    decorationColor: fg,
                    fontWeight: picked ? FontWeight.w500 : null,
                  ),
                ),
              ),
            ),
          ),
        ),
      );
    }

    return Semantics(
      container: true,
      label: names.monthYear(month),
      explicitChildNodes: true,
      child: SizedBox(
        width: weekCol + 7 * _cell,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            SizedBox(
              // Its own height, taller with large text: a finger's room around its
              // buttons spills over rather than growing it.
              height: _header,
              // With dropdowns the lists change the month, and take the arrows' room.
              child: Row(
                children: [
                  if (!widget.dropdowns)
                    SizedBox(
                      width: 32,
                      child: first
                          ? _Nav(
                              icon: isRtl(context)
                                  ? AshlarIconsRoundedStroke.chevronRight
                                  : AshlarIconsRoundedStroke.chevronLeft,
                              label: 'Previous month',
                              onPressed: back,
                            )
                          : null,
                    ),
                  Expanded(child: Center(child: _caption(context, month))),
                  if (!widget.dropdowns)
                    SizedBox(
                      width: 32,
                      child: last
                          ? _Nav(
                              icon: isRtl(context)
                                  ? AshlarIconsRoundedStroke.chevronLeft
                                  : AshlarIconsRoundedStroke.chevronRight,
                              label: 'Next month',
                              onPressed: on,
                            )
                          : null,
                    ),
                ],
              ),
            ),
            const SizedBox(height: 8),
            ExcludeSemantics(
              child: Row(
                children: [
                  if (widget.showWeekNumbers) SizedBox(width: weekCol),
                  for (var i = 0; i < 7; i++)
                    SizedBox(
                      width: _cell,
                      height: _head,
                      child: Center(
                        child: Text(
                          names.weekdaysShort[(firstDay - 1 + i) % 7],
                          style: t.text.xs.copyWith(
                            color: c.mutedForeground,
                            fontSize: 12.8,
                          ),
                        ),
                      ),
                    ),
                ],
              ),
            ),
            const SizedBox(height: 4),
            for (var w = 0; w < weeks; w++)
              Padding(
                padding: const EdgeInsets.only(top: 2),
                child: Row(
                  children: [
                    if (widget.showWeekNumbers)
                      SizedBox(
                        width: weekCol,
                        height: _cell,
                        child: Center(
                          child: Text(
                            '${isoWeek(DateTime(start.year, start.month, start.day + w * 7 + (DateTime.thursday - firstDay + 7) % 7))}',
                            style: t.text.xs.copyWith(color: c.mutedForeground),
                          ),
                        ),
                      ),
                    for (var i = 0; i < 7; i++)
                      SizedBox(
                        width: _cell,
                        height: _cell,
                        child: day(
                          DateTime(
                            start.year,
                            start.month,
                            start.day + w * 7 + i,
                          ),
                        ),
                      ),
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }
}

/// What a calendar shows: its days, the months of a year, or a choice of years.
enum _View { days, months, years }

/// A day that lights up under the pointer.
class _Day extends StatefulWidget {
  const _Day({required this.onTap, required this.builder});

  final VoidCallback? onTap;
  final Widget Function(bool hovered) builder;

  @override
  State<_Day> createState() => _DayState();
}

class _DayState extends State<_Day> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) => MouseRegion(
    cursor: widget.onTap == null
        ? SystemMouseCursors.basic
        : SystemMouseCursors.click,
    onEnter: (_) => setState(() => _hovered = true),
    onExit: (_) => setState(() => _hovered = false),
    child: GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: widget.onTap,
      child: widget.builder(_hovered),
    ),
  );
}

/// The previous and next month buttons.
class _Nav extends StatelessWidget {
  const _Nav({
    required this.icon,
    required this.label,
    required this.onPressed,
  });

  final IconData icon;
  final String label;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 32,
    height: 32,
    child: Button(
      variant: ButtonVariant.ghost,
      size: ButtonSize.icon,
      semanticLabel: label,
      onPressed: onPressed,
      child: AshlarIcon(icon),
    ),
  );
}