diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0dec09..fb20084a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Fixed `MonthViewBuilder` to be generic for improved type safety in `MonthView`. [#524](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/524) - Added `DividerSettings` to customize the dividers in `WeekView` and `MultiDayView`. [#374](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/374), [#430](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/430), [#498](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/498) -- Added `timeSlotColorBuilder` in `DayView` and `WeekView` to customize background color. [#470](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/470) +- Added `timeSlotColorBuilder` in `DayView`, `WeekView` and `MultiDayView` to customize background color. [#470](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/470), [#535](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/535) - Added `pageDate` parameter for timeline label customization with current timestamp fallback. [#527](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/527) - Added `selectedDate` control to `MonthView` for external date management. [#233](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/233) - [BREAKING] Added `isSelected` parameter to `CellBuilder` typedef in `MonthView`. Custom cell builders must be updated to accept this new parameter. diff --git a/example/lib/widgets/multi_day_view_widget.dart b/example/lib/widgets/multi_day_view_widget.dart index fb812969..ce2f2a4f 100644 --- a/example/lib/widgets/multi_day_view_widget.dart +++ b/example/lib/widgets/multi_day_view_widget.dart @@ -28,6 +28,17 @@ class MultiDayViewWidget extends StatelessWidget { thickness: 0.5, height: 0.5, ), + timeSlotColorBuilder: (_, slotStartTime, __, ___) { + final hour = slotStartTime.hour; + final isBusinessHours = hour >= 9 && hour < 17; + final isLunchBreak = hour == 12; + + return isLunchBreak + ? Colors.orange.shade100 + : isBusinessHours + ? Colors.green.shade50 + : Colors.transparent; + }, onTimestampTap: (date) { SnackBar snackBar = SnackBar( content: Text("On tap: ${date.hour} Hr : ${date.minute} Min"), diff --git a/lib/src/components/common_components.dart b/lib/src/components/common_components.dart index cf2f7f29..dfaeb8be 100644 --- a/lib/src/components/common_components.dart +++ b/lib/src/components/common_components.dart @@ -8,6 +8,7 @@ import '../calendar_event_data.dart'; import '../constants.dart'; import '../enumerations.dart'; import '../extensions.dart'; +import '../painters.dart'; import '../typedefs.dart'; import 'components.dart'; @@ -114,3 +115,109 @@ class DefaultEventTile extends StatelessWidget { } } } + +/// Renders time-slot colored backgrounds for calendar views. +/// +/// Accepts one or more [dates] (columns). For a single-day view pass a +/// one-element list; for week/multi-day views pass all visible dates. +/// Each column is painted with per-slot colors returned by +/// [timeSlotColorBuilder] and wrapped in a [RepaintBoundary] so repaints +/// are isolated to individual columns. +class TimeSlotBackgrounds extends StatelessWidget { + /// Dates to render (one column per date). + final List dates; + + /// Pixel width of each date column. + final double columnWidth; + + /// Total pixel height of the scrollable area. + final double height; + + /// Pixel height per minute used to compute slot height. + final double heightPerMinute; + + /// Duration of each time slot. + final MinuteSlotSize minuteSlotSize; + + /// First hour shown in the view. + final int startHour; + + /// Last hour shown in the view. + final int endHour; + + /// Callback that returns a background [Color] for each slot. + final TimeSlotColorBuilder timeSlotColorBuilder; + + const TimeSlotBackgrounds({ + Key? key, + required this.dates, + required this.columnWidth, + required this.height, + required this.heightPerMinute, + required this.minuteSlotSize, + required this.startHour, + required this.endHour, + required this.timeSlotColorBuilder, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // Number of minutes each slot occupies (e.g. 15, 30, or 60). + final slotMinutes = minuteSlotSize.minutes; + // Pixel height of a single time slot rectangle. + final heightPerSlot = heightPerMinute * slotMinutes; + // Total number of slots that fit between startHour and endHour. + final totalSlots = ((endHour - startHour) * 60) ~/ slotMinutes; + // Convenience Duration used when computing slot start/end DateTimes. + final slotDuration = Duration(minutes: slotMinutes); + // Whether the ambient text direction is left-to-right. + // Used to align the background layer correctly in RTL layouts. + final isLtr = Directionality.of(context) == TextDirection.ltr; + + return Align( + alignment: isLtr ? Alignment.centerRight : Alignment.centerLeft, + child: SizedBox( + width: columnWidth * dates.length, + height: height, + child: Row( + children: List.generate(dates.length, (dayIndex) { + final dayDate = dates[dayIndex]; + final dayStart = DateTime( + dayDate.year, + dayDate.month, + dayDate.day, + startHour, + ); + final slotColors = List.generate( + totalSlots, + (slotIndex) { + final slotStartTime = dayStart.add(slotDuration * slotIndex); + final slotEndTime = slotStartTime.add(slotDuration); + return timeSlotColorBuilder( + dayDate, + slotStartTime, + slotEndTime, + slotIndex, + ); + }, + ); + return ClipRect( + child: SizedBox( + width: columnWidth, + height: height, + child: RepaintBoundary( + child: CustomPaint( + painter: TimeSlotBackgroundPainter( + heightPerSlot: heightPerSlot, + slotColors: slotColors, + ), + ), + ), + ), + ); + }), + ), + ), + ); + } +} diff --git a/lib/src/day_view/_internal_day_view_page.dart b/lib/src/day_view/_internal_day_view_page.dart index 9778c2b2..2786a9f5 100644 --- a/lib/src/day_view/_internal_day_view_page.dart +++ b/lib/src/day_view/_internal_day_view_page.dart @@ -5,11 +5,11 @@ import 'package:flutter/material.dart'; import '../components/_internal_components.dart'; +import '../components/common_components.dart'; import '../components/event_scroll_notifier.dart'; import '../enumerations.dart'; import '../event_arrangers/event_arrangers.dart'; import '../event_controller.dart'; -import '../extensions.dart'; import '../modals.dart'; import '../painters.dart'; import '../typedefs.dart'; @@ -246,59 +246,21 @@ class _InternalDayViewPageState /// /// Returns a [Widget] rendering the colored background for all time slots. Widget _buildTimeSlotBackground() { - // Extract the minute duration of each time slot (e.g., 15, 30, 60 minutes) - final slotMinutes = widget.minuteSlotSize.minutes; - // Calculate the pixel height occupied by one time slot - final heightPerSlot = widget.heightPerMinute * slotMinutes; - // Calculate the total number of time slots in the day view - // based on start and end hours - final totalSlots = - ((widget.endHour - widget.startHour) * 60) ~/ slotMinutes; - final startDateTime = DateTime( - widget.date.year, - widget.date.month, - widget.date.day, - widget.startHour, - ); - final slotDuration = Duration(minutes: slotMinutes); - // Generate a list of colors for each time slot of the day - final slotColors = List.generate( - totalSlots, - (slotIndex) { - final slotStartTime = startDateTime.add(slotDuration * slotIndex); - final slotEndTime = slotStartTime.add(slotDuration); - return widget.timeSlotColorBuilder!( - widget.date, - slotStartTime, - slotEndTime, - slotIndex, - ); - }, - ); - final direction = Directionality.of(context); // Calculate the width of the content area, excluding the time line and // hour indicator lines final contentWidth = widget.width - widget.timeLineWidth - widget.hourIndicatorSettings.offset - widget.verticalLineOffset; - - return Align( - alignment: direction == TextDirection.rtl - ? Alignment.centerLeft - : Alignment.centerRight, - child: SizedBox( - width: contentWidth, - height: widget.height, - child: RepaintBoundary( - child: CustomPaint( - painter: TimeSlotBackgroundPainter( - heightPerSlot: heightPerSlot, - slotColors: slotColors, - ), - ), - ), - ), + return TimeSlotBackgrounds( + dates: [widget.date], + columnWidth: contentWidth, + height: widget.height, + heightPerMinute: widget.heightPerMinute, + minuteSlotSize: widget.minuteSlotSize, + startHour: widget.startHour, + endHour: widget.endHour, + timeSlotColorBuilder: widget.timeSlotColorBuilder!, ); } diff --git a/lib/src/multi_day_view/_internal_multi_day_view_page.dart b/lib/src/multi_day_view/_internal_multi_day_view_page.dart index 3be0f648..9e589133 100644 --- a/lib/src/multi_day_view/_internal_multi_day_view_page.dart +++ b/lib/src/multi_day_view/_internal_multi_day_view_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../components/_internal_components.dart'; +import '../components/common_components.dart'; import '../components/event_scroll_notifier.dart'; import '../components/week_view_components.dart'; import '../enumerations.dart'; @@ -174,6 +175,9 @@ class InternalMultiDayViewPage extends StatefulWidget { /// This method will be called when user taps on timestamp in timeline. final TimestampCallback? onTimestampTap; + /// A callback for rendering custom time slot background colors. + final TimeSlotColorBuilder? timeSlotColorBuilder; + /// A single page for week view. const InternalMultiDayViewPage( {Key? key, @@ -225,7 +229,8 @@ class InternalMultiDayViewPage extends StatefulWidget { required this.multiDayViewScrollController, this.lastScrollOffset = 0.0, this.keepScrollOffset = false, - this.showMutliDayBottomLine = true}) + this.showMutliDayBottomLine = true, + this.timeSlotColorBuilder}) : super(key: key); @override @@ -273,6 +278,25 @@ class _InternalMultiDayViewPageState widget.scrollListener(scrollController); } + /// Builds background layers for time slots in the multi-day view. + /// Uses [timeSlotColorBuilder] to determine each slot's color and paints + /// a grid of colored rectangles (one column per day, one row per slot). + /// + /// Parameter: [filteredDates] — visible dates for the page. + /// Returns a [Widget] that paints the slot backgrounds. + Widget _buildMultiDayTimeSlotBackgrounds(List filteredDates) { + return TimeSlotBackgrounds( + dates: filteredDates, + columnWidth: widget.weekTitleWidth, + height: widget.height, + heightPerMinute: widget.heightPerMinute, + minuteSlotSize: widget.minuteSlotSize, + startHour: widget.startHour, + endHour: widget.endHour, + timeSlotColorBuilder: widget.timeSlotColorBuilder!, + ); + } + @override Widget build(BuildContext context) { final filteredDates = _filteredDate(); @@ -393,6 +417,9 @@ class _InternalMultiDayViewPageState width: widget.width, child: Stack( children: [ + // Render time slot backgrounds if color builder is provided + if (widget.timeSlotColorBuilder != null) + _buildMultiDayTimeSlotBackgrounds(filteredDates), CustomPaint( size: Size(widget.width, widget.height), painter: widget.hourLinePainter( diff --git a/lib/src/multi_day_view/multi_day_view.dart b/lib/src/multi_day_view/multi_day_view.dart index 1787f950..c41adcd3 100644 --- a/lib/src/multi_day_view/multi_day_view.dart +++ b/lib/src/multi_day_view/multi_day_view.dart @@ -209,6 +209,10 @@ class MultiDayView extends StatefulWidget { /// Display workday bottom line final bool showWeekDayBottomLine; + /// A callback that resolves slot background color for each visible time slot. + /// Useful for highlighting unavailable hours, business hours, or blocked time. + final TimeSlotColorBuilder? timeSlotColorBuilder; + /// Main widget for week view. const MultiDayView({ Key? key, @@ -269,6 +273,7 @@ class MultiDayView extends StatefulWidget { this.onTimestampTap, this.daysInView = 3, this.showWeekDayBottomLine = true, + this.timeSlotColorBuilder, }) : assert(!(onHeaderTitleTap != null && weekPageHeaderBuilder != null), "can't use [onHeaderTitleTap] & [weekPageHeaderBuilder] simultaneously"), assert((timeLineOffset) >= 0, @@ -323,6 +328,8 @@ class MultiDayViewState extends State> { late HourIndicatorSettings _quarterHourIndicatorSettings; late DividerSettings _dividerSettings; + late TimeSlotColorBuilder? _timeSlotColorBuilder; + late PageController _pageController; late DateWidgetBuilder _timeLineBuilder; @@ -604,6 +611,7 @@ class MultiDayViewState extends State> { scrollPhysics: widget.scrollPhysics, scrollListener: _scrollPageListener, keepScrollOffset: widget.keepScrollOffset, + timeSlotColorBuilder: _timeSlotColorBuilder, ), ); }, @@ -719,6 +727,7 @@ class MultiDayViewState extends State> { _fullDayEventBuilder = widget.fullDayEventBuilder ?? _defaultFullDayEventBuilder; _hourLinePainter = widget.hourLinePainter ?? _defaultHourLinePainter; + _timeSlotColorBuilder = widget.timeSlotColorBuilder; } Widget _defaultFullDayEventBuilder( diff --git a/lib/src/week_view/_internal_week_view_page.dart b/lib/src/week_view/_internal_week_view_page.dart index b2567080..12ced4a7 100644 --- a/lib/src/week_view/_internal_week_view_page.dart +++ b/lib/src/week_view/_internal_week_view_page.dart @@ -292,77 +292,15 @@ class _InternalWeekViewPageState /// Parameter: [filteredDates] — visible dates for the page. /// Returns a [Widget] that paints the slot backgrounds. Widget _buildWeekTimeSlotBackgrounds(List filteredDates) { - // Extract the minute duration of each time slot (e.g., 15, 30, 60 minutes) - final slotMinutes = widget.minuteSlotSize.minutes; - // Calculate the pixel height occupied by one time slot - final heightPerSlot = widget.heightPerMinute * slotMinutes; - // Calculate the total number of time slots to display - // Formula: (number of hours × 60 minutes) ÷ slot size - final totalSlots = - ((widget.endHour - widget.startHour) * 60) ~/ slotMinutes; - - return Align( - // Align the time slot backgrounds to the right (or left in RTL mode) - alignment: Directionality.of(context) == TextDirection.ltr - ? Alignment.centerRight - : Alignment.centerLeft, - child: SizedBox( - // Width spans across all visible days - width: widget.weekTitleWidth * filteredDates.length, - height: widget.height, - child: Row( - children: [ - // Generate a column for each visible day - ...List.generate( - filteredDates.length, - (dayIndex) { - final dayDate = filteredDates[dayIndex]; - final dayStart = DateTime( - dayDate.year, - dayDate.month, - dayDate.day, - widget.startHour, - ); - - final slotDuration = Duration(minutes: slotMinutes); - // Generate a list of colors for each time slot of this day - final slotColors = List.generate( - totalSlots, - (slotIndex) { - // Calculate the start time adn end time of this slot - final slotStartTime = - dayStart.add(slotDuration * slotIndex); - final slotEndTime = slotStartTime.add(slotDuration); - - // Query the color builder to get the background color for this slot - return widget.timeSlotColorBuilder!( - dayDate, - slotStartTime, - slotEndTime, - slotIndex, - ); - }, - ); - - return ClipRect( - child: SizedBox( - width: widget.weekTitleWidth, - height: widget.height, - child: RepaintBoundary( - child: CustomPaint( - painter: TimeSlotBackgroundPainter( - heightPerSlot: heightPerSlot, - slotColors: slotColors, - ), - ), - ), - ), - ); - }, - ), - ], - ), - ), + return TimeSlotBackgrounds( + dates: filteredDates, + columnWidth: widget.weekTitleWidth, + height: widget.height, + heightPerMinute: widget.heightPerMinute, + minuteSlotSize: widget.minuteSlotSize, + startHour: widget.startHour, + endHour: widget.endHour, + timeSlotColorBuilder: widget.timeSlotColorBuilder!, ); } diff --git a/test/time_slot_color_builder_test.dart b/test/time_slot_color_builder_test.dart index 96d1dc86..6d9f7fcc 100644 --- a/test/time_slot_color_builder_test.dart +++ b/test/time_slot_color_builder_test.dart @@ -125,5 +125,85 @@ void main() { expect(uniqueSlotEnds.contains(DateTime(2026, 3, 30, 11)), true); }, ); + + testWidgets( + 'MultiDayView uses minuteSlotSize for slot generation', + (tester) async { + final slotStarts = []; + final slotEnds = []; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 700, + child: MultiDayView( + controller: EventController(), + daysInView: 3, + initialDay: DateTime(2026, 3, 30), + minDay: DateTime(2026, 3, 28), + maxDay: DateTime(2026, 4, 5), + weekTitleHeight: 0, + startHour: 9, + endHour: 11, + minuteSlotSize: MinuteSlotSize.minutes30, + heightPerMinute: 1, + timeLineWidth: 60, + timeSlotColorBuilder: ( + _, + slotStartTime, + slotEndTime, + index, + ) { + slotStarts.add(slotStartTime); + slotEnds.add(slotEndTime); + return index == 0 + ? Colors.blue.shade50 + : Colors.transparent; + }, + ), + ), + ), + ), + ); + + // The visible page always shows exactly 3 days, each with 4 half-hour + // slots between 9:00 and 11:00, giving 12 unique date-time combinations. + // We do not assert on specific calendar dates because MultiDayView aligns + // its page boundaries relative to DateTime.now(), which would make exact + // date checks fragile across test runs on different days. + final uniqueSlotStarts = slotStarts.toSet(); + final uniqueSlotEnds = slotEnds.toSet(); + + // 3 days × 4 slots = 12 unique (date + time) combinations. + expect(uniqueSlotStarts.length, 12); + expect(uniqueSlotEnds.length, 12); + // Each of the 3 visible days must have a 9:00 start and a 10:30 start. + expect( + uniqueSlotStarts.where((dt) => dt.hour == 9 && dt.minute == 0).length, + 3, + ); + expect( + uniqueSlotStarts + .where((dt) => dt.hour == 10 && dt.minute == 30) + .length, + 3, + ); + // Each day's last slot ends at 11:00. + expect( + uniqueSlotEnds.where((dt) => dt.hour == 11 && dt.minute == 0).length, + 3, + ); + + final painters = find.byWidgetPredicate( + (widget) => + widget is CustomPaint && + widget.painter is TimeSlotBackgroundPainter, + ); + // One painter per visible day column. + expect(painters, findsNWidgets(3)); + }, + ); }); }