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.