import 'dart:async'; import 'package:flutter/material.dart'; import 'date_period.dart'; import 'day_type.dart'; import 'unselectable_period_error.dart'; import 'utils.dart'; /// Interface for selection logic of the different date pickers. /// /// T - is selection type. abstract class ISelectablePicker { /// The earliest date the user is permitted to pick. /// /// Time is the midnight. Otherwise [isDisabled] may return undesired result. final DateTime? firstDate; /// The latest date the user is permitted to pick. /// /// Time is the millisecond before next day midnight. /// Otherwise [isDisabled] may return undesired result. final DateTime? lastDate; /// Function returns if day can be selected or not. final SelectableDayPredicate? _selectableDayPredicate; /// StreamController for new selection (T). @protected StreamController? onUpdateController = StreamController.broadcast(); /// Stream with new selected (T) event. /// /// Throws [UnselectablePeriodException] /// if there is any custom disabled date in selected. Stream? get onUpdate => onUpdateController!.stream; /// Constructor with required fields that used in non-abstract methods /// ([isDisabled]). ISelectablePicker( DateTime firstDate, DateTime lastDate, { SelectableDayPredicate? selectableDayPredicate, }) : firstDate = DatePickerUtils.startOfTheDay(firstDate), lastDate = DatePickerUtils.endOfTheDay(lastDate), _selectableDayPredicate = selectableDayPredicate; /// If current selection exists and includes day/days that can't be selected /// according to the [_selectableDayPredicate]' bool? get curSelectionIsCorrupted; /// Returns [DayType] for given [day]. DayType? getDayType(DateTime day); /// Call when user tap on the day cell. void onDayTapped(DateTime selectedDate); /// Returns true if given day is disabled. /// /// Returns whether the given day is before the beginning of the [firstDate] /// or after the end of the [lastDate]. /// /// If [_selectableDayPredicate] is set checks it as well. @protected bool isDisabled(DateTime day) => day.isAfter(lastDate!) || day.isBefore(firstDate!) || !_selectableDayPredicate!(day); /// Closes [onUpdateController]. /// After it [onUpdateController] can't get new events. void dispose() { onUpdateController!.close(); } static bool _defaultSelectableDayPredicate(_) => true; } /// Selection logic for WeekPicker. class WeekSelectable extends ISelectablePicker { /// Initialized in ctor body. DateTime? _firstDayOfSelectedWeek; /// Initialized in ctor body. DateTime? _lastDayOfSelectedWeek; // It is int from 0 to 6 where 0 points to Sunday and 6 points to Saturday. // According to MaterialLocalization.firstDayOfWeekIndex. final int? _firstDayOfWeekIndex; @override bool? get curSelectionIsCorrupted => _checkCurSelection(); /// Creates selection logic for WeekPicker. /// /// Entire week will be selected if /// * it is between [firstDate] and [lastDate] /// * it doesn't include unselectable days according to the /// [selectableDayPredicate] /// /// If one or more days of the week are before [firstDate] /// first selection date will be the same as [firstDate]. /// /// If one or more days of the week are after [lastDate] /// last selection date will be the same as [lastDate]. /// /// If one or more days of week are not selectable according to the /// [selectableDayPredicate] nothing will be returned as selection /// but [UnselectablePeriodException] will be thrown. WeekSelectable(DateTime selectedDate, this._firstDayOfWeekIndex, DateTime firstDate, DateTime lastDate, {SelectableDayPredicate? selectableDayPredicate}) : super(firstDate, lastDate, selectableDayPredicate: selectableDayPredicate!) { DatePeriod selectedWeek = _getNewSelectedPeriod(selectedDate); _firstDayOfSelectedWeek = selectedWeek.start; _lastDayOfSelectedWeek = selectedWeek.end; _checkCurSelection(); } @override DayType getDayType(DateTime date) { DayType result; DatePeriod selectedPeriod = DatePeriod(_firstDayOfSelectedWeek!, _lastDayOfSelectedWeek!); bool selectedPeriodIsBroken = _disabledDatesInPeriod(selectedPeriod).isNotEmpty; if (isDisabled(date)) { result = DayType.disabled; } else if (_isDaySelected(date) && !selectedPeriodIsBroken) { DateTime? firstNotDisabledDayOfSelectedWeek = (_firstDayOfSelectedWeek!.isBefore(firstDate!) ? firstDate : _firstDayOfSelectedWeek)!; DateTime lastNotDisabledDayOfSelectedWeek = (_lastDayOfSelectedWeek!.isAfter(lastDate!) ? lastDate : _lastDayOfSelectedWeek)!; if (DatePickerUtils.sameDate(date, firstNotDisabledDayOfSelectedWeek) && DatePickerUtils.sameDate(date, lastNotDisabledDayOfSelectedWeek)) { result = DayType.single; } else if (DatePickerUtils.sameDate(date, _firstDayOfSelectedWeek!) || DatePickerUtils.sameDate(date, firstDate!)) { result = DayType.start; } else if (DatePickerUtils.sameDate(date, _lastDayOfSelectedWeek!) || DatePickerUtils.sameDate(date, lastDate!)) { result = DayType.end; } else { result = DayType.middle; } } else { result = DayType.notSelected; } return result; } @override void onDayTapped(DateTime selectedDate) { DatePeriod newPeriod = _getNewSelectedPeriod(selectedDate); List customDisabledDays = _disabledDatesInPeriod(newPeriod); customDisabledDays.isEmpty ? onUpdateController!.add(newPeriod) : onUpdateController!.addError( UnselectablePeriodException(customDisabledDays, newPeriod)); } // Returns new selected period according to tapped date. // Doesn't check custom disabled days. // You have to check it separately if it needs. DatePeriod _getNewSelectedPeriod(DateTime tappedDay) { DatePeriod newPeriod; DateTime firstDayOfTappedWeek = DatePickerUtils.getFirstDayOfWeek(tappedDay, _firstDayOfWeekIndex!); DateTime lastDayOfTappedWeek = DatePickerUtils.getLastDayOfWeek(tappedDay, _firstDayOfWeekIndex!); DateTime firstNotDisabledDayOfSelectedWeek = (firstDayOfTappedWeek.isBefore(firstDate!) ? firstDate : firstDayOfTappedWeek)!; DateTime lastNotDisabledDayOfSelectedWeek = (lastDayOfTappedWeek.isAfter(lastDate!) ? lastDate : lastDayOfTappedWeek)!; newPeriod = DatePeriod( firstNotDisabledDayOfSelectedWeek, lastNotDisabledDayOfSelectedWeek); return newPeriod; } bool _isDaySelected(DateTime date) { DateTime startOfTheStartDay = DatePickerUtils.startOfTheDay(_firstDayOfSelectedWeek!); DateTime endOfTheLastDay = DatePickerUtils.endOfTheDay(_lastDayOfSelectedWeek!); return !(date.isBefore(startOfTheStartDay) || date.isAfter(endOfTheLastDay)); } List _disabledDatesInPeriod(DatePeriod period) { List result = []; var date = period.start; while (!date.isAfter(period.end)) { if (isDisabled(date)) result.add(date); date = date.add(Duration(days: 1)); } return result; } // Returns if current selection contains disabled dates. // Returns false if there is no any selection. bool _checkCurSelection() { DatePeriod selectedPeriod = DatePeriod(_firstDayOfSelectedWeek!, _lastDayOfSelectedWeek!); List disabledDates = _disabledDatesInPeriod(selectedPeriod); bool selectedPeriodIsBroken = disabledDates.isNotEmpty; return selectedPeriodIsBroken; } } /// Selection logic for [day_picker.DayPicker]. class DaySelectable extends ISelectablePicker { /// Currently selected date. DateTime selectedDate; @override bool get curSelectionIsCorrupted => _checkCurSelection(); /// Creates selection logic for [day_picker.DayPicker]. /// /// Every day can be selected if it is between [firstDate] and [lastDate] /// and not unselectable according to the [selectableDayPredicate]. /// /// If day is not selectable according to the [selectableDayPredicate] /// nothing will be returned as selection /// but [UnselectablePeriodException] will be thrown. DaySelectable(this.selectedDate, DateTime firstDate, DateTime lastDate, {SelectableDayPredicate? selectableDayPredicate}) : super(firstDate, lastDate, selectableDayPredicate: selectableDayPredicate!); @override DayType getDayType(DateTime date) { DayType result; if (isDisabled(date)) { result = DayType.disabled; } else if (_isDaySelected(date)) { result = DayType.single; } else { result = DayType.notSelected; } return result; } @override void onDayTapped(DateTime selectedDate) { DateTime newSelected = DatePickerUtils.sameDate(firstDate!, selectedDate) ? selectedDate : DateTime(selectedDate.year, selectedDate.month, selectedDate.day); onUpdateController!.add(newSelected); } bool _isDaySelected(DateTime date) => DatePickerUtils.sameDate(date, selectedDate); // Returns if current selection is disabled // according to the [_selectableDayPredicate]. // // Returns false if there is no any selection. bool _checkCurSelection() { bool selectedIsBroken = _selectableDayPredicate!(selectedDate); return selectedIsBroken; } } /// Selection logic for [day_picker.DayPicker] where many single days can be /// selected. class DayMultiSelectable extends ISelectablePicker> { /// Currently selected dates. List selectedDates; /// Creates selection logic for [day_picker.DayPicker]. /// /// Every day can be selected if it is between [firstDate] and [lastDate] /// and not unselectable according to the [selectableDayPredicate]. /// /// If day is not selectable according to the [selectableDayPredicate] /// nothing will be returned as selection /// but [UnselectablePeriodException] will be thrown. DayMultiSelectable(this.selectedDates, DateTime firstDate, DateTime lastDate, {SelectableDayPredicate? selectableDayPredicate}) : super(firstDate, lastDate, selectableDayPredicate: selectableDayPredicate!); @override bool get curSelectionIsCorrupted => _checkCurSelection(); @override DayType getDayType(DateTime date) { DayType result; if (isDisabled(date)) { result = DayType.disabled; } else if (_isDaySelected(date)) { result = DayType.single; } else { result = DayType.notSelected; } return result; } @override void onDayTapped(DateTime selectedDate) { bool alreadyExist = selectedDates.any((d) => DatePickerUtils.sameDate(d, selectedDate)); if (alreadyExist) { List newSelectedDates = List.from(selectedDates) ..removeWhere((d) => DatePickerUtils.sameDate(d, selectedDate)); onUpdateController!.add(newSelectedDates); } else { DateTime newSelected = DatePickerUtils.sameDate(firstDate!, selectedDate) ? selectedDate : DateTime(selectedDate.year, selectedDate.month, selectedDate.day); List newSelectedDates = List.from(selectedDates) ..add(newSelected); onUpdateController!.add(newSelectedDates); } } bool _isDaySelected(DateTime date) => selectedDates.any((d) => DatePickerUtils.sameDate(date, d)); // Returns if current selection is disabled // according to the [_selectableDayPredicate]. // // Returns false if there is no any selection. bool _checkCurSelection() { if (selectedDates.isEmpty) return false; bool selectedIsBroken = selectedDates.every(_selectableDayPredicate!); return selectedIsBroken; } } /// Selection logic for [RangePicker]. class RangeSelectable extends ISelectablePicker { /// Initially selected period. /// /// [selectedPeriod.start] time is midnight. /// [selectedPeriod.end] time is millisecond before next day midnight. DatePeriod? selectedPeriod; /// If [selectedPeriod] has dates unavailable to select. bool? _selectedPeriodIsBroken; @override bool? get curSelectionIsCorrupted => _checkCurSelection(); /// Creates selection logic for [RangePicker]. /// /// Period can be selected if /// * it is between [firstDate] and [lastDate] /// * it doesn't include unselectable days according to the /// [selectableDayPredicate] /// /// /// If one or more days of the period are not selectable according to the /// [selectableDayPredicate] nothing will be returned as selection /// but [UnselectablePeriodException] will be thrown. RangeSelectable( DatePeriod selectedPeriod, DateTime firstDate, DateTime lastDate, {SelectableDayPredicate? selectableDayPredicate}) : selectedPeriod = DatePeriod( DatePickerUtils.startOfTheDay(selectedPeriod.start), DatePickerUtils.endOfTheDay(selectedPeriod.end), ), super(firstDate, lastDate, selectableDayPredicate: selectableDayPredicate!) { _selectedPeriodIsBroken = _disabledDatesInPeriod(selectedPeriod).isNotEmpty; } @override DayType getDayType(DateTime date) { DayType result; if (isDisabled(date)) { result = DayType.disabled; } else if (_isDaySelected(date) && !_selectedPeriodIsBroken!) { if (DatePickerUtils.sameDate(date, selectedPeriod!.start) && DatePickerUtils.sameDate(date, selectedPeriod!.end)) { result = DayType.single; } else if (DatePickerUtils.sameDate(date, selectedPeriod!.start) || DatePickerUtils.sameDate(date, firstDate!)) { result = DayType.start; } else if (DatePickerUtils.sameDate(date, selectedPeriod!.end) || DatePickerUtils.sameDate(date, lastDate!)) { result = DayType.end; } else { result = DayType.middle; } } else { result = DayType.notSelected; } return result; } @override void onDayTapped(DateTime selectedDate) { DatePeriod newPeriod = _getNewSelectedPeriod(selectedDate); List customDisabledDays = _disabledDatesInPeriod(newPeriod); if (customDisabledDays.isEmpty) { selectedPeriod = newPeriod; _selectedPeriodIsBroken = false; onUpdateController!.add(newPeriod); } else { onUpdateController! .addError(UnselectablePeriodException(customDisabledDays, newPeriod)); } } // Returns new selected period according to tapped date. DatePeriod _getNewSelectedPeriod(DateTime tappedDate) { // check if was selected only one date and we should generate period bool sameDate = DatePickerUtils.sameDate(selectedPeriod!.start, selectedPeriod!.end); DatePeriod newPeriod; // Was selected one-day-period. // With new user tap will be generated 2 dates as a period. if (sameDate) { // if user tap on the already selected single day bool selectedAlreadySelectedDay = DatePickerUtils.sameDate(tappedDate, selectedPeriod!.end); bool isSelectedFirstDay = DatePickerUtils.sameDate(tappedDate, firstDate!); bool isSelectedLastDay = DatePickerUtils.sameDate(tappedDate, lastDate!); if (selectedAlreadySelectedDay) { if (isSelectedFirstDay && isSelectedLastDay) { newPeriod = DatePeriod(firstDate!, lastDate!); } else if (isSelectedFirstDay) { newPeriod = DatePeriod(firstDate!, DatePickerUtils.endOfTheDay(firstDate!)); } else if (isSelectedLastDay) { newPeriod = DatePeriod(DatePickerUtils.startOfTheDay(lastDate!), lastDate!); } else { newPeriod = DatePeriod(DatePickerUtils.startOfTheDay(tappedDate), DatePickerUtils.endOfTheDay(tappedDate)); } } else { DateTime startOfTheSelectedDay = DatePickerUtils.startOfTheDay(selectedPeriod!.start); if (!tappedDate.isAfter(startOfTheSelectedDay)) { newPeriod = DatePickerUtils.sameDate(tappedDate, firstDate!) ? DatePeriod(firstDate!, selectedPeriod!.end) : DatePeriod(DatePickerUtils.startOfTheDay(tappedDate), selectedPeriod!.end); } else { newPeriod = DatePickerUtils.sameDate(tappedDate, lastDate!) ? DatePeriod(selectedPeriod!.start, lastDate!) : DatePeriod(selectedPeriod!.start, DatePickerUtils.endOfTheDay(tappedDate)); } } // Was selected 2 dates as a period. // With new user tap new one-day-period will be generated. } else { bool sameAsFirst = DatePickerUtils.sameDate(tappedDate, firstDate!); bool sameAsLast = DatePickerUtils.sameDate(tappedDate, lastDate!); if (sameAsFirst && sameAsLast) { newPeriod = DatePeriod(firstDate!, lastDate!); } else if (sameAsFirst) { newPeriod = DatePeriod(firstDate!, DatePickerUtils.endOfTheDay(firstDate!)); } else if (sameAsLast) { newPeriod = DatePeriod(DatePickerUtils.startOfTheDay(tappedDate), lastDate!); } else { newPeriod = DatePeriod(DatePickerUtils.startOfTheDay(tappedDate), DatePickerUtils.endOfTheDay(tappedDate)); } } return newPeriod; } // Returns if current selection contains disabled dates. // Returns false if there is no any selection. bool _checkCurSelection() { List disabledDates = _disabledDatesInPeriod(selectedPeriod!); bool selectedPeriodIsBroken = disabledDates.isNotEmpty; return selectedPeriodIsBroken; } List _disabledDatesInPeriod(DatePeriod period) { List result = []; var date = period.start; while (!date.isAfter(period.end)) { if (isDisabled(date)) result.add(date); date = date.add(Duration(days: 1)); } return result; } bool _isDaySelected(DateTime date) { DateTime startOfTheStartDay = selectedPeriod!.start; DateTime endOfTheLastDay = selectedPeriod!.end; return !(date.isBefore(startOfTheStartDay) || date.isAfter(endOfTheLastDay)); } } /// Selection logic for [day_picker.MonthPicker.single]. class MonthSelectable extends ISelectablePicker { /// Currently selected date. DateTime selectedDate; @override bool get curSelectionIsCorrupted => _checkCurSelection(); /// Creates selection logic for [day_picker.MonthPicker] /// with single selection. /// /// Every date can be selected if it is between [firstDate] and [lastDate] /// and not unselectable according to the [selectableDayPredicate]. /// /// If date is not selectable according to the [selectableDayPredicate] /// nothing will be returned as selection /// but [UnselectablePeriodException] will be thrown. MonthSelectable(this.selectedDate, DateTime firstDate, DateTime lastDate, {SelectableDayPredicate? selectableDayPredicate}) : super(firstDate, lastDate, selectableDayPredicate: selectableDayPredicate); @override DayType getDayType(DateTime date) { DayType result; if (isDisabled(date)) { result = DayType.disabled; } else if (_isDaySelected(date)) { result = DayType.single; } else { result = DayType.notSelected; } return result; } @override void onDayTapped(DateTime selectedDate) { DateTime newSelected = DatePickerUtils.sameMonth(firstDate!, selectedDate) ? selectedDate : DateTime(selectedDate.year, selectedDate.month, selectedDate.day); onUpdateController!.add(newSelected); } bool _isDaySelected(DateTime date) => DatePickerUtils.sameMonth(date, selectedDate); // Returns if current selection is disabled // according to the [_selectableDayPredicate]. // // Returns false if there is no any selection. bool _checkCurSelection() { bool selectedIsBroken = _selectableDayPredicate!(selectedDate); return selectedIsBroken; } // We only need to know if month of passed day // before the month of the firstDate or after the month of the lastDate. // // Don't need to compare day and time. @protected @override bool isDisabled(DateTime month) { DateTime beginningOfTheFirstDateMonth = DateTime(firstDate!.year, firstDate!.month); DateTime endOfTheLastDateMonth = DateTime( lastDate!.year, lastDate!.month, DatePickerUtils.getDaysInMonth(lastDate!.year, lastDate!.month), 23, 59, 59, 999, ); return month.isAfter(endOfTheLastDateMonth) || month.isBefore(beginningOfTheFirstDateMonth); } } /// Selection logic for [day_picker.MonthPicker.multi] /// where many single months can be selected. class MonthMultiSelectable extends ISelectablePicker> { /// Currently selected dates. List selectedDates; /// Creates selection logic for [day_picker.MonthPicker] with multi selection. /// /// Every day can be selected if it is between [firstDate] and [lastDate] /// and not unselectable according to the [selectableDayPredicate]. /// /// If day is not selectable according to the [selectableDayPredicate] /// nothing will be returned as selection /// but [UnselectablePeriodException] will be thrown. MonthMultiSelectable( this.selectedDates, DateTime firstDate, DateTime lastDate, { SelectableDayPredicate? selectableDayPredicate, }) : super(firstDate, lastDate, selectableDayPredicate: selectableDayPredicate!); @override bool get curSelectionIsCorrupted => _checkCurSelection(); @override DayType getDayType(DateTime date) { DayType result; if (isDisabled(date)) { result = DayType.disabled; } else if (_isDaySelected(date)) { result = DayType.single; } else { result = DayType.notSelected; } return result; } @override void onDayTapped(DateTime selectedDate) { bool alreadyExist = selectedDates.any((d) => DatePickerUtils.sameMonth(d, selectedDate)); if (alreadyExist) { List newSelectedDates = List.from(selectedDates) ..removeWhere((d) => DatePickerUtils.sameMonth(d, selectedDate)); onUpdateController!.add(newSelectedDates); } else { DateTime newSelected = (DatePickerUtils.sameMonth(firstDate!, selectedDate) ? firstDate : DateTime(selectedDate.year, selectedDate.month))!; List newSelectedDates = List.from(selectedDates) ..add(newSelected); onUpdateController!.add(newSelectedDates); } } bool _isDaySelected(DateTime date) => selectedDates.any((d) => DatePickerUtils.sameMonth(date, d)); // Returns if current selection is disabled // according to the [_selectableDayPredicate]. // // Returns false if there is no any selection. bool _checkCurSelection() { if (selectedDates.isEmpty) return false; bool selectedIsBroken = selectedDates.every(_selectableDayPredicate!); return selectedIsBroken; } }