diff --git a/example/lib/main.dart b/example/lib/main.dart index 4909fb8b..d15e5dc8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -22,6 +22,7 @@ class _MyAppState extends State { bool isDarkMode = false; // This widget is the root of your application. + final _controller = EventController()..addAll(_events); @override Widget build(BuildContext context) { return CalendarThemeProvider( @@ -39,7 +40,7 @@ class _MyAppState extends State { : MultiDayViewThemeData.light(), ), child: CalendarControllerProvider( - controller: EventController()..addAll(_events), + controller: _controller, child: MaterialApp( title: 'Flutter Calendar Page Demo', debugShowCheckedModeBanner: false, @@ -149,4 +150,97 @@ List _events = [ title: "Chemistry Viva", description: "Today is Joe's birthday.", ), + CalendarEventData( + date: _now.add(Duration(days: 5)), + startTime: DateTime( + _now.add(Duration(days: 5)).year, + _now.add(Duration(days: 5)).month, + _now.add(Duration(days: 5)).day, + 12, + 13), + endTime: DateTime( + _now.add(Duration(days: 5)).year, + _now.add(Duration(days: 5)).month, + _now.add(Duration(days: 5)).day, + 16, + 13), + endDate: _now.add(Duration(days: 5)), + title: "Team Brainstorming", + description: "Quarterly planning session", + color: Color(0xFF9C27B0), + // 4294127905 + recurrenceSettings: null, + ), + CalendarEventData( + date: _now.add(Duration(days: 2)), + startTime: DateTime( + _now.add(Duration(days: 2)).year, + _now.add(Duration(days: 2)).month, + _now.add(Duration(days: 2)).day, + 18, + 14), + endTime: DateTime( + _now.add(Duration(days: 2)).year, + _now.add(Duration(days: 2)).month, + _now.add(Duration(days: 2)).day, + 20, + 00), + endDate: _now.add(Duration(days: 2)), + title: "Project Review", + description: "Review sprint progress with stakeholders", + color: Color(0xFF4CAF35), + // 4280415061 + recurrenceSettings: null, + ), + CalendarEventData( + date: _now.add(Duration(days: 7)), + startTime: DateTime( + _now.add(Duration(days: 7)).year, + _now.add(Duration(days: 7)).month, + _now.add(Duration(days: 7)).day, + 18, + 14), + endTime: DateTime( + _now.add(Duration(days: 7)).year, + _now.add(Duration(days: 7)).month, + _now.add(Duration(days: 7)).day, + 22, + 14), + endDate: _now.add(Duration(days: 7)), + title: "Design Workshop", + description: "UX/UI design planning for new features", + color: Color(0xFF4CAF2F), + // 4280415055 + recurrenceSettings: null, + ), + CalendarEventData( + date: _now, + startTime: DateTime(_now.year, _now.month, _now.day, 15, 0), + endTime: DateTime(_now.year, _now.month, _now.day, 16, 0), + title: "col1 row1", + description: "", + color: AppColors.red), + CalendarEventData( + date: _now, + startTime: DateTime(_now.year, _now.month, _now.day, 16, 0), + endTime: DateTime(_now.year, _now.month, _now.day, 17, 0), + title: "col1 row2", + description: "", + color: Colors.pink), + CalendarEventData( + date: _now, + startTime: DateTime(_now.year, _now.month, _now.day, 15, 30), + endTime: DateTime(_now.year, _now.month, _now.day, 16, 30), + title: "col2 row1", + description: "col2 row1", + color: Colors.yellow, + ), + CalendarEventData( + date: _now, + startTime: DateTime(_now.year, _now.month, _now.day, 16, 30), + endTime: DateTime(_now.year, _now.month, _now.day, 17, 30), + title: "col2 row2", + description: "col2 row2", + color: AppColors.black, + ), ]; diff --git a/example/lib/widgets/add_event_form.dart b/example/lib/widgets/add_event_form.dart index 934a6bb1..bcda6483 100644 --- a/example/lib/widgets/add_event_form.dart +++ b/example/lib/widgets/add_event_form.dart @@ -103,7 +103,7 @@ class _AddOrEditEventFormState extends State { Text( 'Recurring Event', style: TextStyle( - color: color.surface, + color: color.onSurface, fontSize: 16, ), ), @@ -128,7 +128,6 @@ class _AddOrEditEventFormState extends State { } }); }, - activeColor: color.surface, ), ], ), diff --git a/lib/src/event_arrangers/side_event_arranger.dart b/lib/src/event_arrangers/side_event_arranger.dart index e960421b..79958c57 100644 --- a/lib/src/event_arrangers/side_event_arranger.dart +++ b/lib/src/event_arrangers/side_event_arranger.dart @@ -4,6 +4,11 @@ part of 'event_arrangers.dart'; +/// Arranges overlapping calendar events side by side, ensuring no visual overlap and optimal use of space. +/// Supports multi-day and full-day events, fixed or dynamic width allocation, and configurable edge overlap handling. +/// +/// Events are grouped into columns to prevent overlaps, and each event is positioned and sized for best readability. +/// Use [maxWidth] to constrain event width, and [includeEdges] to control whether touching events are treated as overlapping. class SideEventArranger extends EventArranger { /// This class will provide method that will arrange /// all the events side by side. @@ -17,7 +22,7 @@ class SideEventArranger extends EventArranger { /// should be offset or not. /// /// If includeEdges is true, it will offset the events else it will not. - /// + /// Defaults to false. final bool includeEdges; /// If enough space is available, the event slot will @@ -26,11 +31,14 @@ class SideEventArranger extends EventArranger { /// If max width is not specified, slots will expand to fill the cell. final double? maxWidth; - /// {@macro event_arranger_arrange_method_doc} + /// Arranges events for a calendar day view. /// - /// Make sure that all the events that are passed in [events], must be in - /// ascending order of start time. - + /// Normalizes, sorts, and groups events into columns, then calculates their positions and sizes for rendering. + /// Returns a list of [OrganizedCalendarEventData] with pixel positions and event references. + /// + /// Handles edge cases like empty event lists, events outside the visible range, multi-day events, zero-duration events, and events spanning midnight. + /// + /// Optimized for typical calendar scenarios. Events should be sorted by start time for @override List> arrange({ required List> events, @@ -40,258 +48,424 @@ class SideEventArranger extends EventArranger { required int startHour, required DateTime calendarViewDate, }) { - final totalWidth = width; + if (events.isEmpty) return []; + final startHourInMinutes = startHour * 60; - List<_SideEventConfigs> _categorizedColumnedEvents( - List> events) { - final merged = MergeEventArranger(includeEdges: includeEdges).arrange( - events: events, - height: height, - width: width, - heightPerMinute: heightPerMinute, - startHour: startHour, - calendarViewDate: calendarViewDate, - ); - - final arranged = <_SideEventConfigs>[]; - - for (final event in merged) { - if (event.events.isEmpty) { - // NOTE(parth): This is safety condition. - // This condition should never be true. - // If by chance this becomes true, there is something wrong with - // logic. And that need to be fixed ASAP. - - continue; - } + // Step 1: Normalize all events for the current calendar view date + final normalizedEvents = + _normalizeEventsForDate(events, calendarViewDate, startHourInMinutes); - if (event.events.length > 1) { - // NOTE: This means all the events are overlapping with each other. - // So, we will extract all the events that can be fit in - // Single column without overlapping and run the function - // again for the rest of the events. - - final columnedEvents = _extractSingleColumnEvents( - event.events, - event.endDuration.getTotalMinutes, - ); - - final sided = _categorizedColumnedEvents( - event.events.where((e) => !columnedEvents.contains(e)).toList(), - ); - - var maxColumns = 1; - - for (final event in sided) { - if (event.columns > maxColumns) { - maxColumns = event.columns; - } - } - - arranged.add(_SideEventConfigs( - columns: maxColumns + 1, - event: columnedEvents, - sideEvents: sided, - )); - } else { - // If this block gets executed that means we have only one event. - // Return the event as is. + if (normalizedEvents.isEmpty) return []; + + // Step 2: Sort events by start time for optimal processing + normalizedEvents + .sort((a, b) => a.effectiveStartTime.compareTo(b.effectiveStartTime)); + + // Step 3: Create columns using simple algorithm that prevents overlapping + final columns = _createOptimalColumns(normalizedEvents); - arranged.add(_SideEventConfigs(columns: 1, event: event.events)); + // Step 4: Convert columns to organized calendar event data + return _convertColumnsToOrganizedData(columns, width, height, + heightPerMinute, startHourInMinutes, calendarViewDate); + } + + /// Normalizes events for the given date. + /// + /// Clips multi-day events to the current day's boundaries, filters out events outside visible hours, + /// and converts each event to a standardized internal format for layout. + /// + /// Multi-day event cases: + /// - Same day: uses original start/end times. + /// - Start day: starts at event time, ends at day end. + /// - End day: starts at day start, ends at event time. + /// - Middle day: uses full day (0–1440 minutes). + /// + /// Skips events with null times or outside the visible range. + /// + /// Example: + /// Viewing Jan 2, 2024 for event Jan 1 10:00 PM – Jan 3 2:00 PM + /// → Result: Jan 2 0:00 AM – Jan 2 11:59 PM (full day for middle day) + List<_NormalizedEvent> _normalizeEventsForDate( + List> events, + DateTime calendarViewDate, + int startHourInMinutes, + ) { + final normalizedEvents = <_NormalizedEvent>[]; + + for (final event in events) { + if (event.startTime == null || event.endTime == null) continue; + + int effectiveStartTime; + int effectiveEndTime; + + if (event.isRangingEvent) { + // Handle multi-day events based on which day we're viewing + final isStartDate = + calendarViewDate.isAtSameMomentAs(event.date.withoutTime); + final isEndDate = + calendarViewDate.isAtSameMomentAs(event.endDate.withoutTime); + + if (isStartDate && isEndDate) { + // Event starts and ends on the same day (single day spanning event) + effectiveStartTime = event.startTime!.getTotalMinutes; + effectiveEndTime = event.endTime!.getTotalMinutes; + } else if (isStartDate) { + // First day of multi-day event - start at event time, end at day end + effectiveStartTime = event.startTime!.getTotalMinutes; + effectiveEndTime = Constants.minutesADay; + } else if (isEndDate) { + // Last day of multi-day event - start at day start, end at event time + effectiveStartTime = 0; + effectiveEndTime = event.endTime!.getTotalMinutes; + } else { + // Middle day of multi-day event - full day + effectiveStartTime = 0; + effectiveEndTime = Constants.minutesADay; } + } else { + // Regular single-day event + effectiveStartTime = event.startTime!.getTotalMinutes; + effectiveEndTime = event.endTime!.getTotalMinutes; + } + + // Skip events that don't appear in the visible time range + final visibleStart = startHourInMinutes; + final visibleEnd = Constants.minutesADay; + + if (effectiveEndTime <= visibleStart || + effectiveStartTime >= visibleEnd) { + continue; } - return arranged; + normalizedEvents.add(_NormalizedEvent( + originalEvent: event, + effectiveStartTime: effectiveStartTime, + effectiveEndTime: effectiveEndTime, + isMultiDay: event.isRangingEvent, + isFullDay: event.isFullDayEvent, + )); } - List> _arrangeEvents( - List<_SideEventConfigs> events, double width, double offset) { - final arranged = >[]; - - for (final event in events) { - final slotWidth = - math.min(width / event.columns, maxWidth ?? double.maxFinite); - - if (event.event.isNotEmpty) { - // TODO(parth): Arrange events and add it in arranged. - - arranged.addAll(event.event.map((e) { - final startTime = e.startTime!; - final endTime = e.endTime!; - - int eventStart; - int eventEnd; - - if (e.isRangingEvent) { - // Handle multi-day events differently based on which day is currently being viewed - final isStartDate = - calendarViewDate.isAtSameMomentAs(e.date.withoutTime); - final isEndDate = - calendarViewDate.isAtSameMomentAs(e.endDate.withoutTime); - - if (isStartDate && isEndDate) { - // Single day event with start and end time - eventStart = startTime.getTotalMinutes - (startHourInMinutes); - eventEnd = endTime.getTotalMinutes - (startHourInMinutes) <= 0 - ? Constants.minutesADay - (startHourInMinutes) - : endTime.getTotalMinutes - (startHourInMinutes); - } else if (isStartDate) { - // First day - show from start time to end of day - eventStart = startTime.getTotalMinutes - (startHourInMinutes); - eventEnd = Constants.minutesADay - (startHourInMinutes); - } else if (isEndDate) { - // Last day - show from start of day to end time - eventStart = 0; - eventEnd = endTime.getTotalMinutes - (startHourInMinutes) <= 0 - ? Constants.minutesADay - (startHourInMinutes) - : endTime.getTotalMinutes - (startHourInMinutes); - } else { - // Middle days - show full day - eventStart = 0; - eventEnd = Constants.minutesADay - (startHourInMinutes); - } - } else { - // Single day event - use normal start/end times - eventStart = startTime.getTotalMinutes - (startHourInMinutes); - eventEnd = endTime.getTotalMinutes - (startHourInMinutes) <= 0 - ? Constants.minutesADay - (startHourInMinutes) - : endTime.getTotalMinutes - (startHourInMinutes); - } - - // Ensure values are within valid range - eventStart = math.max(0, eventStart); - eventEnd = math.min( - Constants.minutesADay - (startHourInMinutes), - eventEnd, - ); - - final top = eventStart * heightPerMinute; - - // Calculate visibleMinutes (the total minutes displayed in the view) - final visibleMinutes = Constants.minutesADay - (startHourInMinutes); - - // Check if event ends at or beyond the visible area - final bottom = eventEnd >= visibleMinutes - ? 0.0 // Event extends to bottom of view - : height - eventEnd * heightPerMinute; - - return OrganizedCalendarEventData( - left: offset, - right: totalWidth - (offset + slotWidth), - top: top, - bottom: bottom, - startDuration: startTime.copyFromMinutes(eventStart), - endDuration: endTime.copyFromMinutes(eventEnd), - events: [e], - calendarViewDate: calendarViewDate, - ); - })); - } + return normalizedEvents; + } + + /// Groups events into columns so that no two events in the same column overlap. + /// + /// Uses a first-fit approach: each event is placed in the leftmost column where it does not overlap with existing events. + /// This minimizes the number of columns and creates a compact layout. + /// + /// Handles edge cases like empty input, single events, no overlaps, and all events overlapping. + List>> _createOptimalColumns( + List<_NormalizedEvent> events) { + if (events.isEmpty) return []; + + final columns = >>[]; + + for (final event in events) { + int targetColumn = -1; - if (event.sideEvents.isNotEmpty) { - arranged.addAll(_arrangeEvents( - event.sideEvents, - math.max(0, width - slotWidth), - slotWidth + offset, - )); + // Check each existing column to see if this event can fit + for (int i = 0; i < columns.length; i++) { + if (_canEventFitInColumn(event, columns[i])) { + targetColumn = i; + break; } } - return arranged; + // If no available column found, create a new one + if (targetColumn == -1) { + columns.add([event]); + } else { + columns[targetColumn].add(event); + } } - // By default the offset will be 0. + return columns; + } - final columned = _categorizedColumnedEvents(events); - final arranged = _arrangeEvents(columned, totalWidth, 0); - return arranged; + /// Returns true if a new event can be placed in a column without overlapping any existing events. + /// + /// Iterates through events in the target column and returns false immediately if any overlap is found. + /// Optimized for early exit and minimal memory usage. + /// + /// Used for column selection and layout validation. + bool _canEventFitInColumn( + _NormalizedEvent event, List<_NormalizedEvent> column) { + for (final existing in column) { + if (_eventsOverlap(event, existing)) { + return false; + } + } + return true; } - List> _extractSingleColumnEvents( - List> events, int end) { - // Find the longest event from the list. - final longestEvent = events.fold>( - events.first, - (e1, e2) => e1.duration > e2.duration ? e1 : e2, - ); - - // Create a new list from events and remove the longest one from it. - final searchEvents = [...events]..remove(longestEvent); - - // Create a new list for events in single column. - // Right now it has longest event, - // By the end of the function, this will have the list of the events, - // that are not intersecting with each other. - // and this will be returned from the function. - final columnedEvents = [longestEvent]; - - // Calculate effective end minute from latest columned event. - var endMinutes = longestEvent.endTime!.getTotalMinutes; - - // Run the loop while effective end minute of columned events are - // less than end. - while (endMinutes < end && searchEvents.isNotEmpty) { - // Maps the event with it's duration. - final mappings = >{}; - - // Create a new list from searchEvents. - for (final event in [...searchEvents]) { - // Need to add logic to include edges... - final start = event.startTime!.getTotalMinutes; - - // TODO(parth): Need to improve this. - // This does not handle the case where there is a event before the - // longest event which is not intersecting. - // - if (start < endMinutes || (includeEdges && start == endMinutes)) { - // Remove search event from list so, we do not iterate through it - // again. - searchEvents.remove(event); - } else { - // Add the event in mappings. - final diff = event.startTime!.getTotalMinutes - endMinutes; + /// Returns true if two events overlap in time, considering [includeEdges]. + /// + /// If [includeEdges] is false, events that only touch at edges are not considered overlapping. + /// If true, touching events are treated as overlapping. + /// + /// Handles edge cases like zero-duration events and identical start/end times. + /// Optimized for fast arithmetic comparison. + bool _eventsOverlap(_NormalizedEvent event1, _NormalizedEvent event2) { + if (includeEdges) { + // Events overlap if they have any overlapping time (including touching edges) + return event1.effectiveStartTime <= event2.effectiveEndTime && + event2.effectiveStartTime <= event1.effectiveEndTime; + } else { + // Events overlap only if they have actual time overlap (not just touching) + return event1.effectiveStartTime < event2.effectiveEndTime && + event2.effectiveStartTime < event1.effectiveEndTime; + } + } - mappings.addAll({ - diff: event, - }); - } + /// Converts columns to organized event data and calculates positions and sizes for rendering. + /// + /// Uses either fixed maxWidth or dynamic width allocation based on [maxWidth]. + /// Handles time and width calculations, positioning, and result generation. + /// + /// Returns a list of [OrganizedCalendarEventData] with pixel positions and event references. + /// Handles empty columns and invalid dimensions gracefully. + List> _convertColumnsToOrganizedData( + List>> columns, + double totalWidth, + double height, + double heightPerMinute, + int startHourInMinutes, + DateTime calendarViewDate, + ) { + final result = >[]; + final columnCount = columns.length; + + if (columnCount == 0) return result; + + // Two different strategies based on whether maxWidth is specified + if (maxWidth != null) { + // Strategy 1: Fixed width columns when maxWidth is specified + _processWithFixedMaxWidth(columns, result, totalWidth, height, + heightPerMinute, startHourInMinutes, calendarViewDate); + } else { + // Strategy 2: Dynamic width allocation when maxWidth is NOT specified + _processWithDynamicWidth(columns, result, totalWidth, height, + heightPerMinute, startHourInMinutes, calendarViewDate); + } + + return result; + } + + /// Handles event positioning when a maximum width constraint is specified. + /// + /// Calculates optimal width for each event, applies [maxWidth], and positions events to minimize gaps. + /// Uses actual column widths for positioning and ensures consistent appearance. + /// + /// Handles edge cases like maxWidth exceeding available space, multiple events per column, and very small maxWidth values. + void _processWithFixedMaxWidth( + List>> columns, + List> result, + double totalWidth, + double height, + double heightPerMinute, + int startHourInMinutes, + DateTime calendarViewDate, + ) { + final columnCount = columns.length; + if (columnCount == 0) return; + + // Calculate actual widths for all events first to avoid gaps + final eventWidths = {}; + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) { + final column = columns[columnIndex]; + for (final normalizedEvent in column) { + // Calculate dynamic width available to the right + final dynamicWidth = _calculateAvailableWidthToRight( + normalizedEvent, columns, columnIndex, totalWidth); + + // Take minimum of dynamic width and maxWidth (maxWidth is absolute pixels) + final maxWidthPixels = maxWidth!; + eventWidths[normalizedEvent.hashCode] = + math.min(dynamicWidth, maxWidthPixels); } + } - // This can be any integer larger than 1440 as one day has 1440 minutes. - // so, different of 2 events end and start time will never be greater than - // 1440. - var min = 4000; + // Position events to eliminate gaps by tracking actual positions + double currentLeftPosition = 0.0; - for (final mapping in mappings.entries) { - if (mapping.key < min) { - min = mapping.key; - } + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) { + final column = columns[columnIndex]; + + // Find the maximum width needed for this column + double maxColumnWidth = double.maxFinite; + for (final normalizedEvent in column) { + final eventWidth = eventWidths[normalizedEvent.hashCode]!; + maxColumnWidth = math.min(maxColumnWidth, eventWidth); + } + + for (final normalizedEvent in column) { + final event = normalizedEvent.originalEvent; + + final displayStartTime = math.max( + 0, normalizedEvent.effectiveStartTime - startHourInMinutes); + final displayEndTime = math.min( + Constants.minutesADay - startHourInMinutes, + normalizedEvent.effectiveEndTime - startHourInMinutes); + + final actualWidth = eventWidths[normalizedEvent.hashCode]!; + + final top = displayStartTime * heightPerMinute; + final visibleMinutes = Constants.minutesADay - startHourInMinutes; + final bottom = displayEndTime >= visibleMinutes + ? 0.0 + : height - displayEndTime * heightPerMinute; + + result.add(OrganizedCalendarEventData( + left: currentLeftPosition, + right: math.max(0.0, totalWidth - currentLeftPosition - actualWidth), + top: top, + bottom: bottom, + startDuration: event.startTime!.copyFromMinutes(displayStartTime), + endDuration: event.endTime!.copyFromMinutes(displayEndTime), + events: [event], + calendarViewDate: calendarViewDate, + )); } - if (mappings[min] != null) { - // If mapping had min event, add it in columnedEvents, - // and remove it from searchEvents so, we do not iterate through it - // again. - columnedEvents.add(mappings[min]!); - searchEvents.remove(mappings[min]); + // Move to next column position based on actual width used + currentLeftPosition += maxColumnWidth; + } + } - endMinutes = mappings[min]!.endTime!.getTotalMinutes; + /// Handles event positioning when no maximum width constraint is specified. + /// + /// Events expand to use all available horizontal space for optimal readability, stopping at overlapping events in columns to the right. + /// Uses equal column distribution for left positioning and calculates available width for each event. + /// + /// Ensures maximum readability, no wasted space, and adapts to variable content and overlap scenarios. + void _processWithDynamicWidth( + List>> columns, + List> result, + double totalWidth, + double height, + double heightPerMinute, + int startHourInMinutes, + DateTime calendarViewDate, + ) { + final columnCount = columns.length; + + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) { + final column = columns[columnIndex]; + + for (final normalizedEvent in column) { + final event = normalizedEvent.originalEvent; + + final displayStartTime = math.max( + 0, normalizedEvent.effectiveStartTime - startHourInMinutes); + final displayEndTime = math.min( + Constants.minutesADay - startHourInMinutes, + normalizedEvent.effectiveEndTime - startHourInMinutes); + + // Calculate available width by checking what's to the right + final availableWidth = _calculateAvailableWidthToRight( + normalizedEvent, columns, columnIndex, totalWidth); + + // Use standard column positioning for left offset + final baseSlotWidth = totalWidth / columnCount; + final leftOffset = columnIndex * baseSlotWidth; + + // Use the available width (can extend beyond original column) + final actualWidth = availableWidth; + + final top = displayStartTime * heightPerMinute; + final visibleMinutes = Constants.minutesADay - startHourInMinutes; + final bottom = displayEndTime >= visibleMinutes + ? 0.0 + : height - displayEndTime * heightPerMinute; + + result.add(OrganizedCalendarEventData( + left: leftOffset, + right: math.max(0.0, totalWidth - leftOffset - actualWidth), + top: top, + bottom: bottom, + startDuration: event.startTime!.copyFromMinutes(displayStartTime), + endDuration: event.endTime!.copyFromMinutes(displayEndTime), + events: [event], + calendarViewDate: calendarViewDate, + )); } } + } - return columnedEvents; + /// Calculates how much horizontal space an event can use by checking for blocking events in columns to the right. + /// + /// Scans columns to the right and limits width at the first blocking event, guaranteeing a minimum width. + /// Optimized for early exit and efficient overlap detection. + /// + /// Handles edge cases like rightmost columns, all columns blocked, and zero available space. + double _calculateAvailableWidthToRight( + _NormalizedEvent targetEvent, + List>> allColumns, + int targetColumnIndex, + double totalWidth, + ) { + final columnCount = allColumns.length; + final baseSlotWidth = totalWidth / columnCount; + final startPosition = targetColumnIndex * baseSlotWidth; + + // Start with remaining space from current position to end + double availableWidth = totalWidth - startPosition; + + // Check each column to the right to see if any events overlap with our target event + for (int rightColumnIndex = targetColumnIndex + 1; + rightColumnIndex < columnCount; + rightColumnIndex++) { + final rightColumn = allColumns[rightColumnIndex]; + bool hasBlockingEvent = false; + + // Check if any event in this right column overlaps with our target event + for (final rightEvent in rightColumn) { + if (_eventsOverlap(targetEvent, rightEvent)) { + hasBlockingEvent = true; + break; + } + } + + if (hasBlockingEvent) { + // This column blocks further expansion, limit width to this position + final blockingPosition = rightColumnIndex * baseSlotWidth; + availableWidth = blockingPosition - startPosition; + break; + } + } + + // Ensure minimum width of at least one column + return math.max(baseSlotWidth, availableWidth); } } -class _SideEventConfigs { - final int columns; - final List> event; - final List<_SideEventConfigs> sideEvents; +/// Internal event data structure for calendar layout. +/// +/// Stores normalized start/end times (minutes since midnight), multi-day/full-day flags, and a reference to the original event. +/// Used for efficient overlap detection, layout calculations, and rendering in calendar views. +class _NormalizedEvent { + /// Reference to the original calendar event data + final CalendarEventData originalEvent; + + /// Event start time in minutes since midnight (0-1439) + final int effectiveStartTime; + + /// Event end time in minutes since midnight (0-1440) + final int effectiveEndTime; + + /// Whether this event spans multiple calendar days + final bool isMultiDay; + + /// Whether this is a full-day event + final bool isFullDay; - const _SideEventConfigs({ - this.event = const [], - required this.columns, - this.sideEvents = const [], + const _NormalizedEvent({ + required this.originalEvent, + required this.effectiveStartTime, + required this.effectiveEndTime, + required this.isMultiDay, + required this.isFullDay, }); }