DEV3 Technologies home

Availability calendar with range select

Month grid that blocks booked days, lets users choose a start and end date and refuses ranges containing unavailable days.

FlutterFormsfluttercalendarbookingdaterange

Code

import 'package:flutter/material.dart';

class AvailabilityCalendar extends StatefulWidget {
  const AvailabilityCalendar({super.key, required this.blocked, required this.onRange});
  final Set<DateTime> blocked; // dateOnly values that cannot be booked
  final ValueChanged<DateTimeRange> onRange;

  @override
  State<AvailabilityCalendar> createState() => _AvailabilityCalendarState();
}

class _AvailabilityCalendarState extends State<AvailabilityCalendar> {
  late DateTime _month = DateUtils.dateOnly(DateTime.now()).copyWith(day: 1);
  DateTime? _start, _end;

  bool _isBlocked(DateTime d) =>
      d.isBefore(DateUtils.dateOnly(DateTime.now())) || widget.blocked.contains(d);

  bool _rangeClear(DateTime a, DateTime b) {
    for (var d = a; !d.isAfter(b); d = DateUtils.addDaysToDate(d, 1)) {
      if (_isBlocked(d)) return false;
    }
    return true;
  }

  void _tap(DateTime d) {
    setState(() {
      if (_start == null || _end != null) {
        _start = d; // begin a new selection
        _end = null;
      } else if (d.isBefore(_start!) || !_rangeClear(_start!, d)) {
        _start = d; // earlier day or blocked gap in between: restart here
      } else {
        _end = d;
        widget.onRange(DateTimeRange(start: _start!, end: d));
      }
    });
  }

  bool _inRange(DateTime d) => _start != null && !d.isBefore(_start!) && !d.isAfter(_end ?? _start!);

  @override
  Widget build(BuildContext context) {
    final loc = MaterialLocalizations.of(context);
    final scheme = Theme.of(context).colorScheme;
    final days = DateUtils.getDaysInMonth(_month.year, _month.month);
    final lead = DateUtils.firstDayOffset(_month.year, _month.month, loc);
    return Column(children: [
      Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
        IconButton(onPressed: () => setState(() => _month = DateUtils.addMonthsToMonthDate(_month, -1)), icon: const Icon(Icons.chevron_left)),
        Text(loc.formatMonthYear(_month), style: Theme.of(context).textTheme.titleMedium),
        IconButton(onPressed: () => setState(() => _month = DateUtils.addMonthsToMonthDate(_month, 1)), icon: const Icon(Icons.chevron_right)),
      ]),
      GridView.count(
        crossAxisCount: 7,
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        children: [
          for (var i = 0; i < lead; i++) const SizedBox.shrink(),
          for (var n = 1; n <= days; n++)
            Builder(builder: (context) {
              final d = DateTime(_month.year, _month.month, n);
              final blocked = _isBlocked(d);
              final selected = _inRange(d);
              return InkResponse(
                onTap: blocked ? null : () => _tap(d),
                child: Container(
                  margin: const EdgeInsets.all(2),
                  alignment: Alignment.center,
                  decoration: BoxDecoration(
                    color: selected ? scheme.primary : null,
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: Text('$n', style: TextStyle(
                    decoration: blocked ? TextDecoration.lineThrough : null,
                    color: selected ? scheme.onPrimary : (blocked ? scheme.outline : null),
                  )),
                ),
              );
            }),
        ],
      ),
    ]);
  }
}

How it works

What it solves

Rental and booking listings need a calendar that shows what is taken and only produces valid ranges. The stock date range dialog cannot grey out arbitrary days and then reject a range spanning one of them.

How it works

The widget draws a month with a seven column GridView. DateUtils.firstDayOffset adds the correct number of leading blanks for the locale's first weekday. A day is blocked if it is in the past or inside the blocked set of date-only values, and blocked cells are struck through and ignore taps.

Selection is a tiny state machine: first tap sets the start, the second sets the end. Before accepting the end, _rangeClear walks every day in between; if a blocked day is found or the tap is earlier than the start, the tap becomes a new start instead. Only a complete valid range is passed to onRange.

Adapting it

Load blocked for the visible months from your backend and pass normalised dates with DateUtils.dateOnly. Add minimum nights by checking the length before calling onRange. Weekday headings can be drawn from MaterialLocalizations.narrowWeekdays.

Pitfalls

  • Set membership relies on exact date-only values; a stray time component breaks matching.
  • Walking each day is fine for months, but not for multi-year ranges.
  • Time zone shifts near midnight can move a date, so build dates from the venue's calendar fields.

How to use it

Copy to lib/booking/. No packages.

AvailabilityCalendar(
  blocked: {DateTime(2026, 12, 24), DateTime(2026, 12, 25)},
  onRange: (r) => setState(() => chosen = r),
)

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets