Are you tired of the limitations of Flutter's default calendar widgets? Want to create a fully customizable calendar that matches your app's design and business logic? In this comprehensive tutorial, we'll build a powerful custom calendar widget from scratch that supports both single and multi-date selection, business hours integration, and stunning animations.
Whether you're building a booking app, a scheduling dashboard, or a personal planner, you'll eventually run into the need for a custom calendar—one that does more than just show dates. Maybe you need to highlight closed days, support multiple date selections, or even show available time slots.
Unfortunately, the default Flutter widgets don't cut it. That's why in this tutorial, we'll build a fully customizable calendar widget using Flutter that's both flexible and easy to integrate into any project.
No more bloated packages or hardcoded hacks—just clean, scalable code that you can use and modify as needed.
🎯 What We'll Build
By the end of this tutorial, you'll have:
- ✅ A beautiful, dark-themed calendar widget
- ✅ Single and multi-date selection modes
- ✅ Business hours integration (mark closed days)
- ✅ Smooth animations and transitions
- ✅ Custom styling and theming
- ✅ Range selection with visual feedback
- ✅ Smart date validation
🚀 Getting Started
Note: I have used a venue example, but you can adapt the TimeModel
and logic for any other use case, such as doctor appointments, resource booking, or personal scheduling as per your need.
Prerequisites
- Flutter SDK (latest stable version)
- Basic knowledge of Flutter widgets and state management
- Understanding of DateTime manipulation in Dart
Project Setup
-
Create the Project: If you haven't already, create a new Flutter project and navigate into its directory:
flutter create custom_calendar_demo cd custom_calendar_demo
-
Update
pubspec.yaml
: Open yourpubspec.yaml
file and add thedev_dependencies
as shown in the "Project Setup" section above.
# ... (existing content) dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 build_runner: ^2.4.12 json_serializable: ^6.8.0 # ... (rest of the file)
Install Dependencies: Run
flutter pub get
in your terminal to fetch all the required packages.-
Create Files: Create the following files and folders inside your
lib
directory and paste the respective code into them:-
lib/times_model.dart
-
lib/primary_button.dart
-
lib/CustomCalendarView.dart
-
lib/SelectionModeScreen.dart
- Replace the content of
lib/main.dart
with the providedmain.dart
code.
-
-
Generate
times_model.g.dart
: Since we are usingjson_serializable
, you need to run the build runner to generate the necessary serialization code.
flutter pub run build_runner build
This command will create
lib/times_model.g.dart
. You only need to run this command once, or whenever you make changes totimes_model.dart
.
Let's Dive into Customization Part
1. Creating the TimesModel
Let's start by creating a model to handle business hours and venue availability:
// times_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'times_model.g.dart';
@JsonSerializable(explicitToJson: true)
class TimesModel {
@JsonKey(name: 'isOpen', defaultValue: false)
bool isOpen;
@JsonKey(name: 'openTime')
String? openTime;
@JsonKey(name: 'closeTime')
String? closeTime;
TimesModel({
required this.isOpen,
this.openTime,
this.closeTime,
});
factory TimesModel.fromJson(Map<String, dynamic> json) =>
_$TimesModelFromJson(json);
Map<String, dynamic> toJson() => _$TimesModelToJson(this);
}
Why this model?
This structure allows you to easily integrate with backend APIs that provide hours data.
-
isOpen
: A boolean flag indicating whether the venue (or resource) is open on a particular day. -
openTime
andcloseTime
: Optional strings to specify the exact opening and closing times. This is useful for displaying specific hours or for more granular time slot selection in advanced implementations. - The
@JsonSerializable
annotations andfactory TimesModel.fromJson
/toJson
methods enable automatic JSON serialization/deserialization, making it easy to work with data from REST APIs.
Don't forget to run the code generation:
flutter packages pub run build_runner build
2. Creating the PrimaryButton Component
Let's create a reusable button component for our calendar:
// primary_button.dart
import 'package:flutter/material.dart';
class PrimaryButton extends StatefulWidget {
final Function onTap;
final String? title;
final bool? isLoading;
final bool? isDisabled;
final double? fontSize;
final String? disabledText;
final Color color;
const PrimaryButton({
super.key,
required this.onTap,
this.title,
this.isLoading,
this.isDisabled,
this.fontSize,
this.color = Colors.deepPurple,
this.disabledText,
});
@override
State<PrimaryButton> createState() => _PrimaryButtonState();
}
class _PrimaryButtonState extends State<PrimaryButton> {
@override
Widget build(BuildContext context) {
if (widget.isLoading ?? false) {
return const Center(
child: CircularProgressIndicator(
color: Colors.deepPurpleAccent,
),
);
}
return InkWell(
onTap: (widget.isDisabled ?? false)
? () {
if (widget.disabledText != null) {
// Handle disabled state
}
}
: () => widget.onTap(),
child: Container(
width: double.infinity,
height: 50,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: (widget.isDisabled ?? false)
? Colors.grey.shade400
: widget.color,
boxShadow: const [
BoxShadow(
color: Color.fromRGBO(100, 93, 93, 0.4),
blurRadius: 10,
offset: Offset(0, 5),
),
],
),
child: Center(
child: Text(
widget.title ?? 'Continue',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: widget.fontSize,
),
),
),
),
);
}
}
🗓️ Building the Custom Calendar Widget
Now for the main event - our custom calendar widget.It's a StatefulWidget
that manages the calendar's state, including selected dates, current month, and various display logics. This is where the magic happens:
// CustomCalendarView.dart
import 'package:flutter/material.dart';
import 'times_model.dart';
import 'primary_button.dart';
class CustomCalendarView extends StatefulWidget {
final DateTime? initialDate;
final DateTime? minDate;
final DateTime? maxDate;
final Function(DateTime?, DateTime?)? onDateSelected;
final bool multiDay;
final Color primaryColor;
final Color backgroundColor;
final Map<String, TimesModel>? timeslots;
const CustomCalendarView({
super.key,
this.initialDate,
this.minDate,
this.maxDate,
this.onDateSelected,
this.multiDay = false,
this.primaryColor = const Color(0xFF5A9FE2),
this.backgroundColor = const Color(0xFF1E1E1E),
this.timeslots,
});
@override
CustomCalendarViewState createState() => CustomCalendarViewState();
}
Key Features
-
Flexible Configuration
-
multiDay
: Toggle between single and range selection -
timeslots
: Pass business hours to mark closed days -
primaryColor
&backgroundColor
: Custom theming -
minDate
&maxDate
: Restrict selectable date ranges
-
-
Business Logic Integration
- The
timeslots
parameter accepts a map of weekday names toTimesModel
objects - This allows you to mark specific days as closed (e.g., Sundays for a business)
- Perfect for booking systems, appointment schedulers, or any app with operating hours
- The
🎨 Calendar State Management
Let's implement the state management for our calendar:
class CustomCalendarViewState extends State<CustomCalendarView> {
late DateTime _currentDate;
DateTime? _startDate;
DateTime? _endDate;
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_currentDate = widget.initialDate ?? DateTime.now();
_scrollController = ScrollController();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToMonth(_currentDate);
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
// Smooth scrolling to specific month
void _scrollToMonth(DateTime date) {
int monthOffset = (date.year - _currentDate.year) * 12 +
(date.month - _currentDate.month);
if (monthOffset >= 0 && monthOffset < 12) {
double offset = monthOffset * 360;
_scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
🏢 Business Hours Integration
One of the most powerful features of our calendar is the business hours integration. Here's how it works:
String _getWeekdayName(DateTime date) {
const weekdayNames = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday',
];
return weekdayNames[date.weekday - 1];
}
bool _isVenueOpen(DateTime date) {
if (widget.timeslots == null) return true;
final dayName = _getWeekdayName(date);
final businessHour = widget.timeslots![dayName];
return businessHour?.isOpen ?? true;
}
bool _isVenueClosed(DateTime date) {
return !_isVenueOpen(date);
}
Real-World Example: Restaurant Booking System
Imagine you're building a restaurant booking app. Your restaurant is:
- Open: Monday-Saturday (9 AM - 6 PM)
- Closed: Sundays
🎯 Smart Date Selection Logic
Our calendar includes intelligent date selection with validation:
void _selectDate(DateTime date) {
setState(() {
if (widget.multiDay) {
if (_startDate == null || (_startDate != null && _endDate != null)) {
_startDate = date;
_endDate = null;
} else if (_startDate != null && _endDate == null) {
if (date.isBefore(_startDate!)) {
_endDate = _startDate;
_startDate = date;
} else {
_endDate = date;
}
// Check for closed days in range
final closedDays = _getClosedDaysInRange(_startDate!, _endDate!);
if (closedDays.isNotEmpty) {
_showClosedDaysSnackbar(closedDays);
}
}
} else {
// Single day selection
if (_isVenueClosed(date)) {
_showSingleClosedDaySnackbar(date);
return;
}
_startDate = date;
_endDate = null;
}
});
}
User Feedback for Closed Days
The calendar provides intelligent feedback when users select closed days:
void _showClosedDaysSnackbar(List<DateTime> closedDays) {
if (closedDays.isEmpty) return;
String message;
if (closedDays.length == 1) {
final day = closedDays.first;
final dayName = _getWeekdayName(day);
message = 'The venue is closed on $dayName (${day.day}/${day.month})';
} else {
String daysList = closedDays.map((day) {
final dayName = _getWeekdayName(day);
return '$dayName (${day.day}/${day.month})';
}).join(', ');
message = 'The venue is closed on the following days: $daysList';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message, style: const TextStyle(color: Colors.white)),
backgroundColor: Colors.red.shade600,
duration: const Duration(seconds: 5),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
🎨 Advanced UI Styling
Our calendar features sophisticated styling with smooth animations:
Color _getDateBackgroundColor(
DateTime date,
bool isSelected,
bool isInRange,
bool isRangeStart,
bool isRangeEnd,
bool isVenueClosed,
) {
if (widget.multiDay && isInRange && !isVenueClosed) {
return widget.primaryColor.withValues(alpha: .2);
}
return Colors.transparent;
}
BorderRadius _getDateBorderRadius(
DateTime date,
bool isRangeStart,
bool isRangeEnd,
bool isInRange,
) {
if (!widget.multiDay) return BorderRadius.circular(22);
if (isRangeStart && isRangeEnd) {
return BorderRadius.circular(22);
} else if (isRangeStart) {
return const BorderRadius.only(
topLeft: Radius.circular(22),
bottomLeft: Radius.circular(22),
);
} else if (isRangeEnd) {
return const BorderRadius.only(
topRight: Radius.circular(22),
bottomRight: Radius.circular(22),
);
} else if (isInRange) {
return BorderRadius.zero;
}
return BorderRadius.circular(22);
}
📱 Creating the Demo Screen
Let's create a demo screen to showcase our calendar's different modes:
// SelectionModeScreen.dart
import 'package:flutter/material.dart';
import 'CustomCalendarView.dart';
import 'times_model.dart';
class SelectionModeScreen extends StatelessWidget {
const SelectionModeScreen({super.key});
@override
Widget build(BuildContext context) {
final Map<String, TimesModel> dummyTimeslots = {
"Monday": TimesModel(isOpen: true, openTime: "09:00 AM", closeTime: "06:00 PM"),
"Tuesday": TimesModel(isOpen: true, openTime: "09:00 AM", closeTime: "06:00 PM"),
"Wednesday": TimesModel(isOpen: true, openTime: "09:00 AM", closeTime: "06:00 PM"),
"Thursday": TimesModel(isOpen: true, openTime: "09:00 AM", closeTime: "06:00 PM"),
"Friday": TimesModel(isOpen: true, openTime: "09:00 AM", closeTime: "06:00 PM"),
"Saturday": TimesModel(isOpen: true),
"Sunday": TimesModel(isOpen: false), // Closed on Sundays
};
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text('Selection Mode'),
),
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildModeButton(
context,
'Single Day Selection',
() => CustomCalendarView(multiDay: false),
),
const SizedBox(height: 24),
_buildModeButton(
context,
'Multi-Day Selection',
() => CustomCalendarView(multiDay: true),
),
const SizedBox(height: 24),
_buildModeButton(
context,
'With Business Hours',
() => CustomCalendarView(
multiDay: true,
timeslots: dummyTimeslots,
),
),
],
),
),
),
),
);
}
Widget _buildModeButton(BuildContext context, String title, Widget Function() builder) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => builder()),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
),
);
}
}
🚀 Running the Application
Update your main.dart
file:
// main.dart
import 'package:flutter/material.dart';
import 'SelectionModeScreen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Custom Calendar Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const SelectionModeScreen(),
);
}
}
Full CustomCalendarView Code :
import 'package:dev_to_codes/times_model.dart';
import 'package:dev_to_codes/primary_button.dart';
import 'package:flutter/material.dart';
class CustomCalendarView extends StatefulWidget {
final DateTime? initialDate;
final DateTime? minDate;
final DateTime? maxDate;
final Function(DateTime?, DateTime?)? onDateSelected;
final bool multiDay;
final Color primaryColor;
final Color backgroundColor;
final Map<String, TimesModel>? timeslots;
const CustomCalendarView({
super.key,
this.initialDate,
this.minDate,
this.maxDate,
this.onDateSelected,
this.multiDay = false,
this.primaryColor = const Color(0xFF5A9FE2),
this.backgroundColor = const Color(0xFF1E1E1E),
this.timeslots,
});
@override
CustomCalendarViewState createState() => CustomCalendarViewState();
}
class CustomCalendarViewState extends State<CustomCalendarView> {
late DateTime _currentDate;
DateTime? _startDate;
DateTime? _endDate;
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_currentDate = widget.initialDate ?? DateTime.now();
_scrollController = ScrollController();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToMonth(_currentDate);
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _scrollToMonth(DateTime date) {
int monthOffset =
(date.year - _currentDate.year) * 12 +
(date.month - _currentDate.month);
if (monthOffset >= 0 && monthOffset < 12) {
double offset = monthOffset * 360;
_scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
String _getWeekdayName(DateTime date) {
const weekdayNames = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
return weekdayNames[date.weekday - 1];
}
bool _isVenueOpen(DateTime date) {
if (widget.timeslots == null) return true;
final dayName = _getWeekdayName(date);
final businessHour = widget.timeslots![dayName];
return businessHour?.isOpen ?? true;
}
bool _isVenueClosed(DateTime date) {
return !_isVenueOpen(date);
}
List<DateTime> _getClosedDaysInRange(DateTime start, DateTime end) {
List<DateTime> closedDays = [];
DateTime current = start;
while (current.isBefore(end.add(Duration(days: 1)))) {
if (_isVenueClosed(current)) {
closedDays.add(current);
}
current = current.add(Duration(days: 1));
}
return closedDays;
}
void _showClosedDaysSnackbar(List<DateTime> closedDays) {
if (closedDays.isEmpty) return;
String message;
String daysList;
if (closedDays.length == 1) {
final day = closedDays.first;
final dayName = _getWeekdayName(day);
daysList = '$dayName (${day.day}/${day.month})';
message = 'The venue is closed on $daysList';
} else {
daysList = closedDays
.map((day) {
final dayName = _getWeekdayName(day);
return '$dayName (${day.day}/${day.month})';
})
.join(', ');
message = 'The venue is closed on the following days: $daysList';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message, style: const TextStyle(color: Colors.white)),
backgroundColor: Colors.red.shade600,
duration: const Duration(seconds: 5),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
void _showSingleClosedDaySnackbar(DateTime date) {
final dayName = _getWeekdayName(date);
final message =
'The venue is closed on $dayName (${date.day}/${date.month}/${date.year})\nPlease select a date when the venue is open for booking.';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message, style: const TextStyle(color: Colors.white)),
backgroundColor: Colors.red.shade600,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
List<String> get _monthNames => [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
List<String> get _dayNames => ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
int _getBusinessDaysInRange(DateTime start, DateTime end) {
int businessDays = 0;
DateTime current = start;
while (current.isBefore(end.add(const Duration(days: 1)))) {
if (_isVenueOpen(current)) {
businessDays++;
}
current = current.add(const Duration(days: 1));
}
return businessDays;
}
Widget _buildHeader() {
String headerText;
if (widget.multiDay) {
if (_startDate != null && _endDate != null) {
int totalDays = _endDate!.difference(_startDate!).inDays + 1;
int businessDays = _getBusinessDaysInRange(_startDate!, _endDate!);
headerText = '$totalDays days ($businessDays Open days)';
} else {
headerText = 'Select dates';
}
} else {
headerText = _startDate != null ? 'Selected date' : 'Select date';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const Icon(Icons.close, color: Colors.white, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Text(
headerText,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget daysOfWeekHeaderSection() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children:
_dayNames
.map(
(day) => Expanded(
child: Center(
child: Text(
day,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
),
)
.toList(),
),
);
}
Widget monthCalendarSection(DateTime monthDate) {
final firstDayOfMonth = DateTime(monthDate.year, monthDate.month, 1);
final lastDayOfMonth = DateTime(monthDate.year, monthDate.month + 1, 0);
final firstWeekday = firstDayOfMonth.weekday;
List<Widget> dayWidgets = [];
for (int i = 1; i < firstWeekday; i++) {
dayWidgets.add(const SizedBox());
}
for (int day = 1; day <= lastDayOfMonth.day; day++) {
final date = DateTime(monthDate.year, monthDate.month, day);
final isSelected = _isDateSelected(date);
final isInRange = _isDateInRange(date);
final isRangeStart = _isRangeStart(date);
final isRangeEnd = _isRangeEnd(date);
final isToday = _isSameDay(date, DateTime.now());
final isEnabled = _isDateEnabled(date);
final isVenueClosed = _isVenueClosed(date);
dayWidgets.add(
GestureDetector(
onTap: isEnabled ? () => _selectDate(date) : null,
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: _getDateBackgroundColor(
date,
isSelected,
isInRange,
isRangeStart,
isRangeEnd,
isVenueClosed,
),
borderRadius: _getDateBorderRadius(
date,
isRangeStart,
isRangeEnd,
isInRange,
),
border: _getDateBorder(
date,
isSelected,
isInRange,
isRangeStart,
isRangeEnd,
isVenueClosed,
),
),
child: Center(
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _getDateCircleColor(
isRangeStart,
isRangeEnd,
isVenueClosed,
isSelected,
),
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$day',
style: TextStyle(
color: _getDateTextColor(
date,
isSelected,
isInRange,
isRangeStart,
isRangeEnd,
isToday,
isEnabled,
isVenueClosed,
),
fontSize: 16,
fontWeight:
(isRangeStart ||
isRangeEnd ||
(!widget.multiDay && isSelected) ||
isToday)
? FontWeight.w600
: FontWeight.normal,
),
),
),
),
),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Text(
'${_monthNames[monthDate.month - 1]} ${monthDate.year}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GridView.count(
crossAxisCount: 7,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1,
children: dayWidgets,
),
),
],
);
}
Border? _getDateBorder(
DateTime date,
bool isSelected,
bool isInRange,
bool isRangeStart,
bool isRangeEnd,
bool isVenueClosed,
) {
if (isVenueClosed && !isSelected && !isInRange) {
return Border.all(color: Colors.red.withValues(alpha: 0.6), width: 1.5);
}
return null;
}
Color _getDateBackgroundColor(
DateTime date,
bool isSelected,
bool isInRange,
bool isRangeStart,
bool isRangeEnd,
bool isVenueClosed,
) {
if (widget.multiDay && isInRange && !isVenueClosed) {
return widget.primaryColor.withValues(alpha: .2);
}
return Colors.transparent;
}
Color _getDateCircleColor(
bool isRangeStart,
bool isRangeEnd,
bool isVenueClosed,
bool isSelected,
) {
if ((isRangeStart || isRangeEnd) && !isVenueClosed) {
return widget.primaryColor;
}
if ((isRangeStart || isRangeEnd) && isVenueClosed) {
return Colors.red.withValues(alpha: 0.8);
}
return Colors.transparent;
}
BorderRadius _getDateBorderRadius(
DateTime date,
bool isRangeStart,
bool isRangeEnd,
bool isInRange,
) {
if (!widget.multiDay) return BorderRadius.circular(22);
if (isRangeStart && isRangeEnd) {
return BorderRadius.circular(22);
} else if (isRangeStart) {
return const BorderRadius.only(
topLeft: Radius.circular(22),
bottomLeft: Radius.circular(22),
);
} else if (isRangeEnd) {
return const BorderRadius.only(
topRight: Radius.circular(22),
bottomRight: Radius.circular(22),
);
} else if (isInRange) {
return BorderRadius.zero;
}
return BorderRadius.circular(22);
}
Color _getDateTextColor(
DateTime date,
bool isSelected,
bool isInRange,
bool isRangeStart,
bool isRangeEnd,
bool isToday,
bool isEnabled,
bool isVenueClosed,
) {
if (isRangeStart || isRangeEnd || (!widget.multiDay && isSelected)) {
return Colors.white;
}
if (isToday && !isVenueClosed) return widget.primaryColor;
if (isToday && isVenueClosed) return Colors.red;
if (!isEnabled) return Colors.white30;
if (isVenueClosed) return Colors.red.withValues(alpha: 0.9);
return Colors.white;
}
void _selectDate(DateTime date) {
setState(() {
if (widget.multiDay) {
if (_startDate == null || (_startDate != null && _endDate != null)) {
_startDate = date;
_endDate = null;
} else if (_startDate != null && _endDate == null) {
if (date.isBefore(_startDate!)) {
_endDate = _startDate;
_startDate = date;
} else {
_endDate = date;
}
final closedDays = _getClosedDaysInRange(_startDate!, _endDate!);
if (closedDays.isNotEmpty) {
_showClosedDaysSnackbar(closedDays);
}
}
} else {
if (_isVenueClosed(date)) {
_showSingleClosedDaySnackbar(date);
return;
}
_startDate = date;
_endDate = null;
}
});
}
bool _isDateSelected(DateTime date) =>
_isSameDay(date, _startDate) || _isSameDay(date, _endDate);
bool _isDateInRange(DateTime date) {
if (!widget.multiDay || _startDate == null || _endDate == null) {
return false;
}
return date.isAfter(_startDate!) && date.isBefore(_endDate!);
}
bool _isRangeStart(DateTime date) =>
_startDate != null && _isSameDay(date, _startDate!);
bool _isRangeEnd(DateTime date) =>
_endDate != null && _isSameDay(date, _endDate!);
bool _isSameDay(DateTime date1, DateTime? date2) {
if (date2 == null) return false;
return date1.year == date2.year &&
date1.month == date2.month &&
date1.day == date2.day;
}
bool _isDateEnabled(DateTime date) {
final today = DateTime.now();
final dateOnly = DateTime(date.year, date.month, date.day);
final todayOnly = DateTime(today.year, today.month, today.day);
if (dateOnly.isBefore(todayOnly)) {
return false;
}
if (widget.minDate != null) {
final minDateOnly = DateTime(
widget.minDate!.year,
widget.minDate!.month,
widget.minDate!.day,
);
if (dateOnly.isBefore(minDateOnly)) return false;
}
if (widget.maxDate != null) {
final maxDateOnly = DateTime(
widget.maxDate!.year,
widget.maxDate!.month,
widget.maxDate!.day,
);
if (dateOnly.isAfter(maxDateOnly)) return false;
}
if (_isVenueClosed(date)) {
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
bool hasSelection =
_startDate != null && (widget.multiDay || _startDate != null);
return Scaffold(
backgroundColor: widget.backgroundColor,
body: SafeArea(
child: Column(
children: [
_buildHeader(),
daysOfWeekHeaderSection(),
Expanded(
child: ListView.builder(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
cacheExtent: 2000,
itemCount: widget.multiDay ? 4 : 1,
itemBuilder: (context, index) {
final monthDate = DateTime(
_currentDate.year,
_currentDate.month + index,
1,
);
return monthCalendarSection(monthDate);
},
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
width: double.infinity,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedOpacity(
opacity: hasSelection ? 1.0 : 0.6,
duration: const Duration(milliseconds: 200),
child: PrimaryButton(
color: hasSelection ? Colors.deepPurpleAccent : Colors.grey,
title: widget.multiDay ? 'CONFIRM DATES' : 'CONFIRM DATE',
onTap: () {
if (hasSelection) {
debugPrint(
'The date Selected are :$_startDate and $_endDate',
);
if (widget.multiDay &&
_startDate != null &&
_endDate != null) {
int businessDays = _getBusinessDaysInRange(
_startDate!,
_endDate!,
);
debugPrint('Business days in range: $businessDays');
}
widget.onDateSelected?.call(_startDate, _endDate);
Navigator.of(context).pop();
}
},
),
),
),
),
],
),
),
);
}
}
🎯 Key Features in Action
1. Single Date Selection
- Tap any available date to select it
- Closed days are visually marked and show warnings when tapped
- Perfect for appointment booking systems
2. Multi-Date Range Selection
- Tap to set start date, tap again to set end date
- Visual range highlighting with smooth animations
- Automatic calculation of business days in the selected range
3. Business Hours Integration
- Closed days are marked with red styling
- Smart validation prevents selection of unavailable dates
- Helpful snackbar notifications for user guidance
4. Smart Date Validation
- Prevents selection of past dates
- Respects min/max date constraints
- Integrates seamlessly with business hours
🖼️ Calendar Selection Previews
![]() |
![]() |
![]() |
🔧 Customization and Extensions
1. Theming Options
You can easily customize the calendar's appearance:
CustomCalendarView(
primaryColor: Colors.green, // Selection color
backgroundColor: Colors.grey[900], // Background color
multiDay: true,
)
2. Adding Event Integration
Extend the TimesModel
to include events:
@JsonSerializable(explicitToJson: true)
class TimesModel {
bool isOpen;
String? openTime;
String? closeTime;
List<String>? events; // Add this line
// ... rest of the implementation
}
3. API Integration
Connect to your backend API:
Future<Map<String, TimesModel>> fetchBusinessHours() async {
final response = await http.get(Uri.parse('your-api-endpoint'));
final Map<String, dynamic> data = json.decode(response.body);
return data.map((key, value) =>
MapEntry(key, TimesModel.fromJson(value))
);
}
4. Advanced Features You Can Add
- Time slot selection within days
- Recurring event patterns
- Holiday integration
- Multi-language support
- Accessibility improvements
- Custom animations
- Export to calendar apps
🎉 Conclusion
Congratulations! You've just built a production-ready custom calendar widget that's:
✅ Highly customizable - Colors, themes, and behavior
✅ Business-logic aware - Integrates with operating hours
✅ User-friendly - Smart validation and helpful feedback
✅ Performance optimized - Efficient rendering and smooth animations
✅ Scalable - Easy to extend with new features
Key Takeaways
- Custom widgets give you complete control over functionality and appearance
- Business logic integration makes your calendar actually useful for real applications
- Good UX design includes validation, feedback, and intuitive interactions
- Modular architecture makes your code maintainable and extensible
What's Next?
- Experiment with different themes and colors
- Add your own features like event scheduling or time slots
- Integrate with your backend API for dynamic business hours
- Share your improvements with the Flutter community!
The complete source code is available on GitHub, and I encourage you to fork it, modify it, and make it your own. Happy coding! 🚀
Found this tutorial helpful? Give it a ❤️ and follow me for more Flutter tutorials and tips!
Top comments (0)