From 4ff4e5222af8b8e01064b663296ae02a5cc89b71 Mon Sep 17 00:00:00 2001 From: Shubham Jitiya Date: Wed, 23 Oct 2024 16:39:34 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Fixes=20issue=20#378:=20=E2=9C=A8Added?= =?UTF-8?q?=20support=20for=20daily,=20weekly=20&=20yearly=20recurrence=20?= =?UTF-8?q?event=20in=20month=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/constants.dart | 2 + example/lib/main.dart | 27 ++ example/lib/pages/event_details_page.dart | 48 ++- example/lib/widgets/add_event_form.dart | 330 ++++++++++++++++++- example/lib/widgets/day_view_widget.dart | 1 + example/lib/widgets/delete_event_dialog.dart | 63 ++++ example/lib/widgets/month_view_widget.dart | 1 + example/lib/widgets/week_view_widget.dart | 1 + lib/src/calendar_event_data.dart | 7 + lib/src/enumerations.dart | 35 ++ lib/src/event_controller.dart | 277 +++++++++++++++- lib/src/modals.dart | 242 +++++++++++++- lib/src/month_view/month_view.dart | 3 +- 13 files changed, 1019 insertions(+), 18 deletions(-) create mode 100644 example/lib/widgets/delete_event_dialog.dart diff --git a/example/lib/constants.dart b/example/lib/constants.dart index 36ec8416..21da78d8 100644 --- a/example/lib/constants.dart +++ b/example/lib/constants.dart @@ -5,6 +5,8 @@ import 'app_colors.dart'; class AppConstants { AppConstants._(); + static final List weekTitles = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + static OutlineInputBorder inputBorder = OutlineInputBorder( borderRadius: BorderRadius.circular(7), borderSide: BorderSide( diff --git a/example/lib/main.dart b/example/lib/main.dart index 146e23af..80985bd7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -42,10 +42,37 @@ List _events = [ startTime: DateTime(_now.year, _now.month, _now.day, 18, 30), endTime: DateTime(_now.year, _now.month, _now.day, 22), ), + CalendarEventData( + date: _now.subtract(Duration(days: 3)), + recurrenceSettings: RecurrenceSettings.withCalculatedEndDate( + startDate: _now.subtract(Duration(days: 3)), + frequency: RepeatFrequency.doNotRepeat, + ), + title: 'Leetcode Contest', + description: 'Give leetcode contest', + ), + CalendarEventData( + date: _now.subtract(Duration(days: 3)), + recurrenceSettings: RecurrenceSettings.withCalculatedEndDate( + startDate: _now.subtract(Duration(days: 3)), + frequency: RepeatFrequency.daily, + recurrenceEndOn: RecurrenceEnd.after, + occurrences: 5, + ), + title: 'Physics test prep', + description: 'Prepare for physics test', + ), CalendarEventData( date: _now.add(Duration(days: 1)), startTime: DateTime(_now.year, _now.month, _now.day, 18), endTime: DateTime(_now.year, _now.month, _now.day, 19), + recurrenceSettings: RecurrenceSettings( + startDate: _now, + endDate: _now.add(Duration(days: 5)), + frequency: RepeatFrequency.daily, + recurrenceEndOn: RecurrenceEnd.after, + occurrences: 5, + ), title: "Wedding anniversary", description: "Attend uncle's wedding anniversary.", ), diff --git a/example/lib/pages/event_details_page.dart b/example/lib/pages/event_details_page.dart index 2785688d..f4625953 100644 --- a/example/lib/pages/event_details_page.dart +++ b/example/lib/pages/event_details_page.dart @@ -1,4 +1,5 @@ import 'package:calendar_view/calendar_view.dart'; +import 'package:example/widgets/delete_event_dialog.dart'; import 'package:flutter/material.dart'; import '../extension.dart'; @@ -6,8 +7,14 @@ import 'create_event_page.dart'; class DetailsPage extends StatelessWidget { final CalendarEventData event; + final DateTime date; + + const DetailsPage({ + required this.event, + required this.date, + super.key, + }); - const DetailsPage({super.key, required this.event}); @override Widget build(BuildContext context) { return Scaffold( @@ -83,17 +90,15 @@ class DetailsPage extends StatelessWidget { SizedBox( height: 10.0, ), - Text(event.description!), + Text(event.description!) ], const SizedBox(height: 50), Row( children: [ Expanded( child: ElevatedButton( - onPressed: () { - CalendarControllerProvider.of(context) - .controller - .remove(event); + onPressed: () async { + await _handleDeleteEvent(context); Navigator.of(context).pop(); }, child: Text('Delete Event'), @@ -111,7 +116,7 @@ class DetailsPage extends StatelessWidget { ), ); - if (result) { + if (result != null) { Navigator.of(context).pop(); } }, @@ -124,4 +129,33 @@ class DetailsPage extends StatelessWidget { ), ); } + + /// Handles the deletion of an event, showing a dialog for repeating events. + /// + /// This method checks if the event is a repeating event. If it is, it shows + /// a dialog to the user to choose the deletion type (e.g., delete this + /// event, delete following events, delete all events). + /// If the event is not repeating, it defaults to deleting all occurrences + /// of the event. + Future _handleDeleteEvent(BuildContext context) async { + DeleteEvent? result; + final isRepeatingEvent = event.recurrenceSettings != null && + (event.recurrenceSettings?.frequency != RepeatFrequency.doNotRepeat); + + if (isRepeatingEvent) { + result = await showDialog( + context: context, + builder: (_) => DeleteEventDialog(), + ); + } else { + result = DeleteEvent.all; + } + if (result != null) { + CalendarControllerProvider.of(context).controller.deleteRecurrenceEvent( + date: date, + event: event, + deleteEventType: result, + ); + } + } } diff --git a/example/lib/widgets/add_event_form.dart b/example/lib/widgets/add_event_form.dart index 40b18718..f6f2b46c 100644 --- a/example/lib/widgets/add_event_form.dart +++ b/example/lib/widgets/add_event_form.dart @@ -25,15 +25,20 @@ class AddOrEditEventForm extends StatefulWidget { class _AddOrEditEventFormState extends State { late DateTime _startDate = DateTime.now().withoutTime; late DateTime _endDate = DateTime.now().withoutTime; + DateTime? _recurrenceEndDate; DateTime? _startTime; DateTime? _endTime; + List _selectedDays = List.filled(7, false); + RepeatFrequency? _selectedFrequency = RepeatFrequency.doNotRepeat; + RecurrenceEnd? _selectedRecurrenceEnd = RecurrenceEnd.never; Color _color = Colors.blue; final _form = GlobalKey(); late final _descriptionController = TextEditingController(); + late final _occurrenceController = TextEditingController(); late final _titleController = TextEditingController(); late final _titleNode = FocusNode(); late final _descriptionNode = FocusNode(); @@ -41,7 +46,6 @@ class _AddOrEditEventFormState extends State { @override void initState() { super.initState(); - _setDefaults(); } @@ -52,6 +56,7 @@ class _AddOrEditEventFormState extends State { _descriptionController.dispose(); _titleController.dispose(); + _occurrenceController.dispose(); super.dispose(); } @@ -102,6 +107,7 @@ class _AddOrEditEventFormState extends State { } _startDate = date.withoutTime; + updateWeekdaysSelection(); if (mounted) { setState(() {}); @@ -137,6 +143,8 @@ class _AddOrEditEventFormState extends State { )); } else { _endDate = date.withoutTime; + _recurrenceEndDate = _endDate; + updateWeekdaysSelection(); } if (mounted) { @@ -247,6 +255,264 @@ class _AddOrEditEventFormState extends State { hintText: "Event Description", ), ), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Repeat', + style: TextStyle( + color: AppColors.black, + fontWeight: FontWeight.w500, + fontSize: 17, + ), + ), + ), + Row( + children: [ + Radio( + value: RepeatFrequency.doNotRepeat, + groupValue: _selectedFrequency, + onChanged: (value) { + setState( + () => _selectedFrequency = value, + ); + }, + ), + Text( + 'Do not repeat', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ), + ], + ), + Row( + children: [ + Radio( + value: RepeatFrequency.daily, + groupValue: _selectedFrequency, + onChanged: (value) { + setState( + () => _selectedFrequency = value, + ); + }, + ), + Text( + 'Daily', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ) + ], + ), + Row( + children: [ + Radio( + value: RepeatFrequency.weekly, + groupValue: _selectedFrequency, + onChanged: (value) { + setState( + () => _selectedFrequency = value, + ); + }, + ), + Text( + 'Weekly', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ), + ], + ), + Row( + children: [ + Radio( + value: RepeatFrequency.monthly, + groupValue: _selectedFrequency, + onChanged: (value) { + setState( + () => _selectedFrequency = value, + ); + }, + ), + Text( + 'Monthly', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ), + ], + ), + Row( + children: [ + Radio( + value: RepeatFrequency.yearly, + groupValue: _selectedFrequency, + onChanged: (value) { + setState( + () => _selectedFrequency = value, + ); + }, + ), + Text( + 'Yearly', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ), + ], + ), + if (_selectedFrequency == RepeatFrequency.weekly) ...[ + Wrap( + children: List.generate(AppConstants.weekTitles.length, (index) { + return ChoiceChip( + label: Text(AppConstants.weekTitles[index]), + showCheckmark: false, + selected: _selectedDays[index], + onSelected: (selected) { + setState(() { + _selectedDays[index] = selected; + if (!_selectedDays.contains(true)) { + _selectedDays[_startDate.weekday - 1] = true; + } + }); + }, + shape: CircleBorder(), + ); + }).toList(), + ), + ], + SizedBox(height: 15), + if (_selectedFrequency != RepeatFrequency.doNotRepeat) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reoccurrence ends on: ', + style: TextStyle( + color: AppColors.black, + fontWeight: FontWeight.w500, + fontSize: 17, + ), + ), + Row( + children: [ + Radio( + value: RecurrenceEnd.never, + groupValue: _selectedRecurrenceEnd, + onChanged: (value) => setState( + () => _selectedRecurrenceEnd = value, + ), + ), + Text( + 'Never', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ), + ], + ), + Row( + children: [ + Radio( + value: RecurrenceEnd.onDate, + groupValue: _selectedRecurrenceEnd, + onChanged: (value) => setState( + () => _selectedRecurrenceEnd = value, + ), + ), + Text( + 'On', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ) + ], + ), + Row( + children: [ + Radio( + value: RecurrenceEnd.after, + groupValue: _selectedRecurrenceEnd, + onChanged: (value) => setState( + () => _selectedRecurrenceEnd = value, + ), + ), + Text( + 'After', + style: TextStyle( + color: AppColors.black, + fontSize: 17, + ), + ), + ], + ), + ], + ), + SizedBox(height: 15), + if (_selectedRecurrenceEnd == RecurrenceEnd.onDate) + DateTimeSelectorFormField( + initialDateTime: _recurrenceEndDate, + decoration: AppConstants.inputDecoration.copyWith( + labelText: 'Ends on', + ), + onSelect: (date) { + if (date.withoutTime.isBefore(_endDate.withoutTime)) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Recurrence ends after end date'), + )); + } else { + _recurrenceEndDate = date.withoutTime; + } + + if (mounted) { + setState(() {}); + } + }, + validator: (value) { + if (value == null || value == "") { + return 'Please select end date.'; + } + + // TODO(Shubham): Add validation of end occurrence >= end date + return null; + }, + textStyle: TextStyle( + color: AppColors.black, + fontSize: 17.0, + ), + onSave: (date) => _recurrenceEndDate = date ?? _recurrenceEndDate, + type: DateTimeSelectionType.date, + ), + if (_selectedRecurrenceEnd == RecurrenceEnd.after) + TextFormField( + controller: _occurrenceController, + style: TextStyle( + color: AppColors.black, + fontSize: 17.0, + ), + keyboardType: TextInputType.number, + minLines: 1, + maxLength: 3, + validator: (value) { + if (value == null || value.trim() == '') { + return 'Please specify occurrences'; + } + + return null; + }, + decoration: AppConstants.inputDecoration.copyWith( + hintText: '30', + suffixText: 'Occurrences', + counterText: '', + ), + ), SizedBox( height: 15.0, ), @@ -284,6 +550,21 @@ class _AddOrEditEventFormState extends State { if (!(_form.currentState?.validate() ?? true)) return; _form.currentState?.save(); + final occurrences = int.tryParse(_occurrenceController.text); + DateTime? endDate; + if (widget.event?.recurrenceSettings?.occurrences != occurrences) { + endDate = null; + } else { + endDate = _recurrenceEndDate; + } + final recurrenceSettings = RecurrenceSettings.withCalculatedEndDate( + startDate: _startDate, + endDate: endDate, + frequency: _selectedFrequency ?? RepeatFrequency.daily, + weekdays: _toWeekdayInIndices, + occurrences: occurrences, + recurrenceEndOn: _selectedRecurrenceEnd ?? RecurrenceEnd.never, + ); final event = CalendarEventData( date: _startDate, @@ -293,14 +574,47 @@ class _AddOrEditEventFormState extends State { color: _color, title: _titleController.text.trim(), description: _descriptionController.text.trim(), + recurrenceSettings: recurrenceSettings, ); widget.onEventAdd?.call(event); _resetForm(); } + /// Get list of weekdays in indices from the selected days + List get _toWeekdayInIndices { + List selectedIndexes = []; + for (int i = 0; i < _selectedDays.length; i++) { + if (_selectedDays[i] == true) { + selectedIndexes.add(i); + } + } + return selectedIndexes; + } + + void updateWeekdaysSelection() { + _selectedDays.fillRange(0, _selectedDays.length, false); + if (_endDate.difference(_startDate).inDays >= 7) { + _selectedDays.fillRange(0, _selectedDays.length, true); + } + DateTime current = _startDate; + while (current.isBefore(_endDate) || current.isAtSameMomentAs(_endDate)) { + _selectedDays[current.weekday - 1] = true; + current = current.add(Duration(days: 1)); + } + } + + /// Set initial selected week to start date + void _setInitialWeekday() { + final currentWeekday = DateTime.now().weekday - 1; + _selectedDays[currentWeekday] = true; + } + void _setDefaults() { - if (widget.event == null) return; + if (widget.event == null) { + _setInitialWeekday(); + return; + } final event = widget.event!; @@ -310,6 +624,14 @@ class _AddOrEditEventFormState extends State { _endTime = event.endTime ?? _endTime; _titleController.text = event.title; _descriptionController.text = event.description ?? ''; + _selectedFrequency = event.recurrenceSettings?.frequency; + _selectedRecurrenceEnd = + event.recurrenceSettings?.recurrenceEndOn ?? RecurrenceEnd.never; + _recurrenceEndDate = event.recurrenceSettings?.endDate ?? _startDate; + _occurrenceController.text = + (event.recurrenceSettings?.occurrences ?? 0).toString(); + event.recurrenceSettings?.weekdays + .forEach((index) => _selectedDays[index] = true); } void _resetForm() { @@ -319,6 +641,10 @@ class _AddOrEditEventFormState extends State { _startTime = null; _endTime = null; _color = Colors.blue; + _recurrenceEndDate = null; + _selectedDays.fillRange(0, _selectedDays.length, true); + _selectedFrequency = RepeatFrequency.doNotRepeat; + _selectedRecurrenceEnd = RecurrenceEnd.never; if (mounted) { setState(() {}); diff --git a/example/lib/widgets/day_view_widget.dart b/example/lib/widgets/day_view_widget.dart index bc8cf3f8..a02c189f 100644 --- a/example/lib/widgets/day_view_widget.dart +++ b/example/lib/widgets/day_view_widget.dart @@ -38,6 +38,7 @@ class DayViewWidget extends StatelessWidget { MaterialPageRoute( builder: (_) => DetailsPage( event: events.first, + date: date, ), ), ); diff --git a/example/lib/widgets/delete_event_dialog.dart b/example/lib/widgets/delete_event_dialog.dart new file mode 100644 index 00000000..31c9b20f --- /dev/null +++ b/example/lib/widgets/delete_event_dialog.dart @@ -0,0 +1,63 @@ +import 'package:calendar_view/calendar_view.dart'; +import 'package:flutter/material.dart'; + +class DeleteEventDialog extends StatefulWidget { + @override + _RadioDialogState createState() => _RadioDialogState(); +} + +class _RadioDialogState extends State { + DeleteEvent _selectedOption = DeleteEvent.current; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Delete recurring event '), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: Text('This event'), + value: DeleteEvent.current, + groupValue: _selectedOption, + onChanged: (deleteType) { + if (deleteType != null) { + setState(() => _selectedOption = deleteType); + } + }, + ), + RadioListTile( + title: Text('This and following events'), + value: DeleteEvent.following, + groupValue: _selectedOption, + onChanged: (deleteType) { + if (deleteType != null) { + setState(() => _selectedOption = deleteType); + } + }, + ), + RadioListTile( + title: Text('All events'), + value: DeleteEvent.all, + groupValue: _selectedOption, + onChanged: (deleteType) { + if (deleteType != null) { + setState(() => _selectedOption = deleteType); + } + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_selectedOption), + child: Text('Done'), + ), + ], + ); + } +} diff --git a/example/lib/widgets/month_view_widget.dart b/example/lib/widgets/month_view_widget.dart index bacec7eb..bdaf73f8 100644 --- a/example/lib/widgets/month_view_widget.dart +++ b/example/lib/widgets/month_view_widget.dart @@ -24,6 +24,7 @@ class MonthViewWidget extends StatelessWidget { MaterialPageRoute( builder: (_) => DetailsPage( event: event, + date: date, ), ), ); diff --git a/example/lib/widgets/week_view_widget.dart b/example/lib/widgets/week_view_widget.dart index 765e8fcb..5c036911 100644 --- a/example/lib/widgets/week_view_widget.dart +++ b/example/lib/widgets/week_view_widget.dart @@ -33,6 +33,7 @@ class WeekViewWidget extends StatelessWidget { MaterialPageRoute( builder: (_) => DetailsPage( event: events.first, + date: date, ), ), ); diff --git a/lib/src/calendar_event_data.dart b/lib/src/calendar_event_data.dart index e6b053fd..45f60544 100644 --- a/lib/src/calendar_event_data.dart +++ b/lib/src/calendar_event_data.dart @@ -44,6 +44,9 @@ class CalendarEventData { /// Define style of description. final TextStyle? descriptionStyle; + /// Define reoccurrence settings + final RecurrenceSettings? recurrenceSettings; + /// {@macro calendar_event_data_doc} CalendarEventData({ required this.title, @@ -55,6 +58,7 @@ class CalendarEventData { this.endTime, this.titleStyle, this.descriptionStyle, + this.recurrenceSettings, DateTime? endDate, }) : _endDate = endDate?.withoutTime, date = date.withoutTime; @@ -119,6 +123,7 @@ class CalendarEventData { "title": title, "description": description, "endDate": endDate, + "recurrenceSettings": recurrenceSettings, }; /// Returns new object of [CalendarEventData] with the updated values defined @@ -135,6 +140,7 @@ class CalendarEventData { TextStyle? descriptionStyle, DateTime? endDate, DateTime? date, + RecurrenceSettings? recurrenceSettings, }) { return CalendarEventData( title: title ?? this.title, @@ -147,6 +153,7 @@ class CalendarEventData { endDate: endDate ?? this.endDate, event: event ?? this.event, titleStyle: titleStyle ?? this.titleStyle, + recurrenceSettings: recurrenceSettings ?? this.recurrenceSettings, ); } diff --git a/lib/src/enumerations.dart b/lib/src/enumerations.dart index 5659e4e9..b33c8d34 100644 --- a/lib/src/enumerations.dart +++ b/lib/src/enumerations.dart @@ -48,3 +48,38 @@ enum LineStyle { /// Dashed line dashed, } + +/// Defines reoccurrence of event: Daily, weekly, monthly or yearly +enum RepeatFrequency { + doNotRepeat, + daily, + weekly, + monthly, + yearly, +} + +/// Defines reoccurrence event ends on: +/// `never` to repeat without any end date specified, +/// `onDate` to repeat till date specified +/// `after` repeat till defined number of occurrence. +enum RecurrenceEnd { + never, + onDate, + after, +} + +/// Specifies the scope of deletion for recurring events in a calendar. +/// +/// This enum is used to determine which instances of a recurring event +/// should be deleted when a deletion action is performed. +/// +/// - [DeleteEvent.all] - Deletes all instances of the recurring event. +/// - [DeleteEvent.current] - Deletes only the currently selected instance +/// of the event. +/// - [DeleteEvent.following] - Deletes the current and all future instances +/// of the recurring event. +enum DeleteEvent { + all, + current, + following, +} diff --git a/lib/src/event_controller.dart b/lib/src/event_controller.dart index 1a131a95..8876ecd0 100644 --- a/lib/src/event_controller.dart +++ b/lib/src/event_controller.dart @@ -6,9 +6,7 @@ import 'dart:collection'; import 'package:flutter/material.dart'; -import 'calendar_event_data.dart'; -import 'extensions.dart'; -import 'typedefs.dart'; +import '../calendar_view.dart'; class EventController extends ChangeNotifier { /// Calendar controller to control all the events related operations like, @@ -73,7 +71,216 @@ class EventController extends ChangeNotifier { //#endregion + //#region Private Methods + + /// Determines whether the given date should be included as a recurring event + /// for daily recurrence settings. + /// + /// Returns `true` if the event should repeat on the given `currentDate`, + /// otherwise returns `false`. + /// `endDate` may change, such as when handling deletions or updates. + /// + /// - If `recurrenceEndDate` is not specified, the event repeats indefinitely + /// and this method returns `true`. + /// - If `recurrenceEndDate` is specified: + /// - The event is included if the `currentDate` is before + /// the `recurrenceEndDate`. + /// - The event is also included on the exact `recurrenceEndDate` + /// (checked using `isAtSameMomentAs`), allowing the event to occur + /// on the last day. + bool _isDailyRecurrence({ + required DateTime currentDate, + required RecurrenceSettings recurrenceSettings, + }) { + final recurrenceEndDate = recurrenceSettings.endDate; + return recurrenceEndDate == null || + (currentDate.isBefore(recurrenceEndDate) || + currentDate.isAtSameMomentAs(recurrenceEndDate)); + } + + /// If the weekday matches with `recurrenceSettings` and there is no end date, + /// the recurrence is infinite + /// + /// + /// If the weekday matches and there is an end date, check if the current date + /// is before or on the end date + /// This ensures the recurrence continues until the specified end date + /// + /// Recurrence endDate may change if event is deleted. + bool _isWeeklyRecurrence({ + required DateTime currentDate, + required RecurrenceSettings recurrenceSettings, + }) { + // Adjust weekday to zero-based indexing and + // check if date’s weekday is in the recurrence weekdays + final isMatchingWeekday = + recurrenceSettings.weekdays.contains(currentDate.weekday - 1); + final recurrenceEndDate = recurrenceSettings.endDate; + + if (!isMatchingWeekday) { + return false; + } + + // If no end date is specified, repeat infinitely + return recurrenceEndDate == null || + (currentDate.isBefore(recurrenceEndDate) || + currentDate.isAtSameMomentAs(recurrenceEndDate)); + } + + // Repeat event on same day + // Returns true if event should repeat on the given date otherwise false. + // For monthly repetition of event event start date & given date should match. + // repetition will include the recurrence end date. + bool _isMonthlyRecurrence({ + required DateTime currentDate, + required DateTime startDate, + required RecurrenceSettings recurrenceSettings, + }) { + // Exclude if day is different + if (currentDate.day != startDate.day) { + return false; + } + + // Continues if day is same + final recurrenceEndDate = recurrenceSettings.endDate; + + return recurrenceEndDate == null || + (currentDate.isBefore(recurrenceEndDate) || + currentDate.isAtSameMomentAs(recurrenceEndDate)); + } + + // If end date is not mentioned repeat infinitely + // If end date is mentioned repeat till end date including last date + // End date will change in case of "Following events" are deleted + bool _isYearlyRecurrence({ + required DateTime currentDate, + required DateTime startDate, + required RecurrenceSettings recurrenceSettings, + }) { + if (currentDate.month != startDate.month || + currentDate.day != startDate.day) { + return false; + } + + final recurrenceEndDate = recurrenceSettings.endDate; + return recurrenceEndDate == null || + (currentDate.isBefore(recurrenceEndDate) || + currentDate.isAtSameMomentAs(recurrenceEndDate)); + } + + // Event is not recurring. So, no need to handle recurrence. + // This method checks for the event whether it should exclude + // or not on given date. + // Event is excluded: + // - If given date is of before event start date + // - If given date is after recurrence end date + // - If given date is in excluded list of events. + // On returning true it excludes event and on false it won't exclude. + bool _isExcluded(RecurrenceSettings settings, DateTime date) { + final recurrenceEndDate = settings.endDate; + return (recurrenceEndDate != null && date.isAfter(recurrenceEndDate)) || + (settings.excludeDates?.contains(date) ?? false); + } + + bool _handleRecurrence({ + required DateTime currentDate, + required DateTime eventStartDate, + required DateTime eventEndDate, + required RecurrenceSettings recurrenceSettings, + }) { + switch (recurrenceSettings.frequency) { + case RepeatFrequency.doNotRepeat: + return currentDate.isAtSameMomentAs(eventStartDate); + case RepeatFrequency.daily: + return _isDailyRecurrence( + currentDate: currentDate, + recurrenceSettings: recurrenceSettings, + ); + case RepeatFrequency.weekly: + return _isWeeklyRecurrence( + currentDate: currentDate, + recurrenceSettings: recurrenceSettings, + ); + case RepeatFrequency.monthly: + return _isMonthlyRecurrence( + currentDate: currentDate, + startDate: eventStartDate, + recurrenceSettings: recurrenceSettings, + ); + case RepeatFrequency.yearly: + return _isYearlyRecurrence( + currentDate: currentDate, + startDate: eventStartDate, + recurrenceSettings: recurrenceSettings); + } + } + + void _deleteCurrentEvent(DateTime date, CalendarEventData event) { + final excludeDates = event.recurrenceSettings?.excludeDates ?? []; + excludeDates.add(date); + final updatedRecurrenceSettings = + event.recurrenceSettings?.copyWith(excludeDates: excludeDates); + final updatedEvent = + event.copyWith(recurrenceSettings: updatedRecurrenceSettings); + update(event, updatedEvent); + } + + /// If the selected date to delete the event is the same as the event's start date, delete all recurrences. + /// Otherwise, delete the event on the selected date and all subsequent recurrences. + void _deleteFollowingEvents(DateTime date, CalendarEventData event) { + final newEndDate = date.subtract( + const Duration(days: 1), + ); + final updatedRecurrenceSettings = event.recurrenceSettings?.copyWith( + endDate: newEndDate, + ); + if (date == event.date) { + remove(event); + } else { + final updatedEvent = + event.copyWith(recurrenceSettings: updatedRecurrenceSettings); + update(event, updatedEvent); + } + } + //#endregion + //#region Public Methods + /// Deletes a recurring event based on the specified deletion type. + /// + /// This method handles the deletion of recurring events by determining the + /// type of deletion + /// requested (all events, the current event, or following events) and + /// performing the appropriate action. + /// + /// Takes the following parameters: + /// - [date]: The date of the event to be deleted. + /// - [event]: The event data to be deleted. + /// - [deleteEventType]: The `DeleteEventType` of deletion to perform + /// (all events, the current event, or following events). + /// + /// The method performs the following actions based on the [deleteEventType]: + /// - [DeleteEvent.all]: Removes the entire series of events. + /// - [DeleteEvent.current]: Deletes only the current event. + /// - [DeleteEvent.following]: Deletes the current event and + /// all subsequent events. + void deleteRecurrenceEvent({ + required DateTime date, + required CalendarEventData event, + required DeleteEvent deleteEventType, + }) { + switch (deleteEventType) { + case DeleteEvent.all: + remove(event); + break; + case DeleteEvent.current: + _deleteCurrentEvent(date, event); + break; + case DeleteEvent.following: + _deleteFollowingEvents(date, event); + break; + } + } + /// Add all the events in the list /// If there is an event with same date then void addAll(List> events) { @@ -137,11 +344,67 @@ class EventController extends ChangeNotifier { {bool includeFullDayEvents = true}) { //ignore: deprecated_member_use_from_same_package if (_eventFilter != null) return _eventFilter!.call(date, this.events); - return _calendarData.getEventsOnDay(date.withoutTime, includeFullDayEvents: includeFullDayEvents); } + /// Retrieves all events for a given date, including repeated events that are + /// not excluded on that day. + /// + /// This method combines events that occur on the specified date with repeated + /// events that are not excluded. + /// It filters out any events that are marked as excluded for the given date. + /// + /// Takes a [date] parameter representing the date for which to + /// retrieve events. + /// Returns a list of [CalendarEventData] objects representing all events on + /// the specified date. + List> getAllEventsOnDay(DateTime date) { + // Get only non-repeating events + final events = getEventsOnDay(date) + .where((event) => event.recurrenceSettings == null) + .toList(); + // Get repeating events + final repeatedEvents = getRepeatedEvents(date); + events.addAll(repeatedEvents); + return events; + } + + /// Returns repeated events on given date. + List> getRepeatedEvents(DateTime date) { + final repeatedEvents = _calendarData.repeatedEvents; + final events = >[]; + + // Iterate through all repeated events and skips + // if the given date is before start date of repeating event + // or if the date is in excluded list of dates. + // We do not need to handle Recurrence for it. + for (final event in repeatedEvents) { + final recurrenceSettings = event.recurrenceSettings; + + if (recurrenceSettings == null || + date.isBefore(event.date) || + _isExcluded(recurrenceSettings, date)) { + continue; + } + + final isRecurrence = _handleRecurrence( + currentDate: date, + eventStartDate: event.date, + eventEndDate: event.endDate, + recurrenceSettings: recurrenceSettings, + ); + + // Add in calendar if event is recurring and + // given date is different from start date of event + // because each event is already added to start date of event + if (isRecurrence) { + events.add(event); + } + } + return events; + } + /// Returns full day events on given day. List> getFullDayEvent(DateTime date) { return _calendarData.getFullDayEvent(date.withoutTime); @@ -179,6 +442,10 @@ class CalendarData { /// available in this list as global itemList of all events). final _eventList = >[]; + /// If recurrence settings exist then get all the repeated events + List> get repeatedEvents => + _eventList.where((event) => event.recurrenceSettings != null).toList(); + UnmodifiableListView> get events => UnmodifiableListView(_eventList); @@ -249,7 +516,6 @@ class CalendarData { // TODO: improve this... if (_eventList.contains(event)) return; - if (event.isFullDayEvent) { addFullDayEvent(event); } else if (event.isRangingEvent) { @@ -329,7 +595,6 @@ class CalendarData { if (includeFullDayEvents) { events.addAll(getFullDayEvent(date)); } - return events; } diff --git a/lib/src/modals.dart b/lib/src/modals.dart index 5fc80acb..c4605f3d 100644 --- a/lib/src/modals.dart +++ b/lib/src/modals.dart @@ -2,10 +2,11 @@ // Use of this source code is governed by a MIT-style license // that can be found in the LICENSE file. +import 'dart:math'; + import 'package:flutter/material.dart'; -import 'enumerations.dart'; -import 'typedefs.dart'; +import '../calendar_view.dart'; /// Settings for hour lines class HourIndicatorSettings { @@ -83,3 +84,240 @@ class LiveTimeIndicatorSettings { showBullet: false, ); } + +/// Set `frequency = RepeatFrequency.daily` to repeat every day +/// starting from event date (Inclusive). +/// +/// Set `frequency = RepeatFrequency.weekly` & provide list of weekdays +/// to repeat on. +/// +/// [startDate]: Defines start date of repeating events. +/// [endDate]: Defines end date of repeating events. +/// [occurrence]: Defines repetition of an event for the given number of +/// occurrences. +/// +/// [frequency]: Defines mode of repetition like repeat daily, weekly, monthly +/// or yearly. +/// +/// [weekdays]: Contains list of weekdays to repeat starting from 0 index. +/// By default selected weekday is the start date of an event. +/// +/// Note: Use constructor .withCalculatedEndDate to calculate +/// end date of recurring event automatically. +class RecurrenceSettings { + RecurrenceSettings({ + required this.startDate, + this.endDate, + this.occurrences, + this.frequency = RepeatFrequency.weekly, + this.recurrenceEndOn = RecurrenceEnd.never, + this.excludeDates, + List? weekdays, + }) : weekdays = weekdays ?? [startDate.weekday]; + + /// If recurrence event does not have an end date it will calculate end date + /// from the start date. + /// + /// Specify `endDate` to end an event on specific date. + /// + /// End date for Repeat Frequency - daily. + /// Ex. If event start date is 11-11-24 and interval is 5 then new end date + /// will be 15-11-24. + /// + /// End date for Repeat Frequency - weekly + /// Ex. If event start date is 1-11-24 and interval is 5 then new end date + /// will be 29-11-24. + RecurrenceSettings.withCalculatedEndDate({ + required this.startDate, + DateTime? endDate, + this.occurrences, + this.frequency = RepeatFrequency.weekly, + this.recurrenceEndOn = RecurrenceEnd.never, + this.excludeDates, + List? weekdays, + }) : weekdays = weekdays ?? [startDate.weekday] { + this.endDate = endDate ?? _getEndDate(startDate); + } + + final DateTime startDate; + late DateTime? endDate; + final int? occurrences; + final RepeatFrequency frequency; + final RecurrenceEnd recurrenceEndOn; + final List weekdays; + final List? excludeDates; + + int get _occurrences => (occurrences ?? 1) - 1; + + /// Calculates the end date for a monthly recurring event + /// based on the start date and the number of occurrences. + /// + /// If the next month does not have the event date and the recurrence + /// is still set to repeat for the given number of occurrences, + /// it will keep looking for a valid date in the following month. + /// + /// Example: If the start date is 29-01-25 and the recurrence ends + /// after 2 occurrences, + /// the end date will be 29-03-25 because February does not have a 29th date. + DateTime get _endDateMonthly { + var repetition = _occurrences; + var nextDate = startDate; + + while (repetition > 0) { + nextDate = DateTime( + nextDate.year, + nextDate.month + 1, + nextDate.day, + ); + + // Adjust the date if the resulting month does not have the same day + // as the start date + // Example: DateTime(2024, 10 + 1, 31) gives 2024-12-01 + // TODO(Shubham): Review if in next consecutive month as well date does not found? + if (nextDate.day != startDate.day) { + nextDate = DateTime( + nextDate.year, + nextDate.month, + startDate.day, + ); + } + repetition--; + } + return nextDate; + } + + /// Returns the calculated end date for the selected weekdays and occurrences, + /// or null if the conditions are not met. + /// + /// This method calculates the end date for a recurring event based + /// on the selected weekdays and the specified occurrences. + /// + /// If the weekday of event start date is not in list of selected weekdays + /// then it will find for the next valid weekdays to repeat on. + /// + /// Example: If the start date is 12-11-24 (Tuesday), and the selected + /// weekdays are [Tuesday, Wednesday] for 3 occurrences, + /// the event will repeat on 12-11-24, 13-11-24, and 19-11-24. + DateTime? get _endDateWeekly { + final repetition = _occurrences; + if (weekdays.isEmpty || repetition <= 0) { + return null; + } + + final weekdayOccurrences = List.filled(7, 0); + + for (var i = 0; i <= repetition; i++) { + final weekDay = weekdays[i % weekdays.length]; + weekdayOccurrences[weekDay]++; + } + + final maxWeekday = weekdayOccurrences.reduce(max); + final indexOfMaxWeekday = weekdayOccurrences.lastIndexOf(maxWeekday); + final endDate = + startDate.add(Duration(days: (maxWeekday - 1) * 7 + indexOfMaxWeekday)); + return endDate; + } + + /// Calculate end date for yearly recurring event + DateTime get _endDateYearly { + var repetition = _occurrences; + var nextDate = startDate; + + // If the start date is not 29th Feb, we can directly calculate last year. + if (startDate.day != 29 && startDate.month != DateTime.february) { + return DateTime( + nextDate.year + repetition, + startDate.month, + startDate.day, + ); + } + // TODO(Shubham): Optimize for larger recurrences if required + while (repetition > 0) { + final newDate = DateTime( + nextDate.year + 1, + startDate.month, + startDate.day, + ); + + // If month changes that means that date does not exist in given year + if (newDate.month != startDate.month) { + nextDate = DateTime( + newDate.year, + ); + continue; + } + nextDate = newDate; + repetition--; + } + return nextDate; + } + + /// Determines the end date for a recurring event based on the + /// `RepeatFrequency` & `RecurrenceEnd`. + /// + /// Returns null if the end date is not applicable. + /// For example: An event that "does not repeat" and event that "never ends". + DateTime? _getEndDate(DateTime endDate) { + if (frequency == RepeatFrequency.doNotRepeat || + recurrenceEndOn == RecurrenceEnd.never) { + return null; + } else if (recurrenceEndOn == RecurrenceEnd.onDate) { + return endDate; + } else if (recurrenceEndOn == RecurrenceEnd.after) { + return _handleOccurrence(endDate); + } else { + return null; + } + } + + /// Calculates the end date to repeat the event for the given number of + /// occurrences. + DateTime? _handleOccurrence(DateTime endDate) { + if (_occurrences < 1) { + return endDate; + } + switch (frequency) { + case RepeatFrequency.doNotRepeat: + return null; + case RepeatFrequency.daily: + return endDate.add(Duration(days: _occurrences)); + case RepeatFrequency.weekly: + return _endDateWeekly ?? endDate; + case RepeatFrequency.monthly: + return _endDateMonthly; + case RepeatFrequency.yearly: + return _endDateYearly; + } + } + + @override + String toString() { + return 'start date: $startDate, ' + 'end date: $endDate, ' + 'interval: $occurrences, ' + 'frequency: $frequency ' + 'weekdays: $weekdays' + 'recurrence Ends on: $recurrenceEndOn' + 'exclude dates: $excludeDates'; + } + + RecurrenceSettings copyWith({ + DateTime? startDate, + DateTime? endDate, + int? occurrences, + RepeatFrequency? frequency, + RecurrenceEnd? recurrenceEndOn, + List? weekdays, + List? excludeDates, + }) { + return RecurrenceSettings( + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + occurrences: occurrences ?? this.occurrences, + frequency: frequency ?? this.frequency, + recurrenceEndOn: recurrenceEndOn ?? this.recurrenceEndOn, + weekdays: weekdays ?? this.weekdays, + excludeDates: excludeDates ?? this.excludeDates, + ); + } +} diff --git a/lib/src/month_view/month_view.dart b/lib/src/month_view/month_view.dart index 51690910..1ead1029 100644 --- a/lib/src/month_view/month_view.dart +++ b/lib/src/month_view/month_view.dart @@ -698,7 +698,8 @@ class _MonthPageBuilder extends StatelessWidget { itemCount: 42, shrinkWrap: true, itemBuilder: (context, index) { - final events = controller.getEventsOnDay(monthDays[index]); + final events = controller.getAllEventsOnDay(monthDays[index]); + return GestureDetector( onTap: () => onCellTap?.call(events, monthDays[index]), onLongPress: () => onDateLongPress?.call(monthDays[index]),