diff --git a/forui/example/lib/main.dart b/forui/example/lib/main.dart index 15b403d85..7bddef031 100644 --- a/forui/example/lib/main.dart +++ b/forui/example/lib/main.dart @@ -47,9 +47,9 @@ class Testing extends StatelessWidget { const Testing({super.key}); @override - Widget build(BuildContext context) => FCalendar.raw( + Widget build(BuildContext context) => FCalendar( start: DateTime(1900, 1, 8), end: DateTime(2024, 7, 10), - selected: _selected.contains, + controller: FCalendarMultiValueController(), ); } diff --git a/forui/lib/src/widgets/calendar/calendar.dart b/forui/lib/src/widgets/calendar/calendar.dart index 57107c3be..c3d36a645 100644 --- a/forui/lib/src/widgets/calendar/calendar.dart +++ b/forui/lib/src/widgets/calendar/calendar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; @@ -10,6 +11,7 @@ import 'package:sugar/sugar.dart'; export 'day/day_picker.dart' show FCalendarDayPickerStyle, FCalendarDayStyle; export 'shared/entry.dart' show FCalendarEntryStyle; export 'shared/header.dart' show FCalendarHeaderStyle, FCalendarPickerType; +export 'calendar_controller.dart'; export 'year_month_picker.dart' show FCalendarYearMonthPickerStyle; /// A calendar. @@ -23,6 +25,9 @@ class FCalendar extends StatelessWidget { /// The style. Defaults to [FThemeData.calendarStyle]. final FCalendarStyle? style; + /// A controller that determines if a date is selected. + final FCalendarController controller; + /// The start date. It is truncated to the nearest date. /// /// ## Contract: @@ -43,9 +48,6 @@ class FCalendar extends StatelessWidget { /// Defaults to returning true for all dates. final Predicate enabled; - /// A predicate that determines if a date is selected. It may be called more than once for a single date. - final Predicate selected; - /// A callback for when the displayed month changes. final ValueChanged? onMonthChange; @@ -57,13 +59,13 @@ class FCalendar extends StatelessWidget { final ValueNotifier _type; final ValueNotifier _month; - /// Creates a [FCalendar] with custom date selection. + /// Creates a [FCalendar]. /// /// [initialDate] defaults to [today]. It is truncated to the nearest date. - FCalendar.raw({ + FCalendar({ + required this.controller, required this.start, required this.end, - required this.selected, this.style, this.enabled = _true, this.onMonthChange, @@ -102,21 +104,28 @@ class FCalendar extends StatelessWidget { ValueListenableBuilder( valueListenable: _type, builder: (context, value, child) => switch (value) { - FCalendarPickerType.day => PagedDayPicker( + FCalendarPickerType.day => ValueListenableBuilder( + valueListenable: controller, + builder: (context, _, __) => PagedDayPicker( style: style, start: start.toLocalDate(), end: end.toLocalDate(), today: today.toLocalDate(), initial: _month.value, enabled: (date) => enabled(date.toNative()), - selected: (date) => selected(date.toNative()), + selected: (date) => controller.contains(date.toNative()), onMonthChange: (date) { _month.value = date; onMonthChange?.call(date.toNative()); }, - onPress: (date) => onPress?.call(date.toNative()), + onPress: (date) { + final native = date.toNative(); + controller.onPress(native); + onPress?.call(native); + }, onLongPress: (date) => onLongPress?.call(date.toNative()), ), + ), FCalendarPickerType.yearMonth => YearMonthPicker( style: style, start: start.toLocalDate(), @@ -141,13 +150,11 @@ class FCalendar extends StatelessWidget { super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('style', style)) + ..add(DiagnosticsProperty('controller', controller)) ..add(DiagnosticsProperty('start', start)) ..add(DiagnosticsProperty('end', end)) ..add(DiagnosticsProperty('today', today)) - ..add(DiagnosticsProperty('type', _type)) - ..add(DiagnosticsProperty('month', _month)) ..add(DiagnosticsProperty('enabled', enabled)) - ..add(DiagnosticsProperty('selected', selected)) ..add(DiagnosticsProperty('onMonthChange', onMonthChange)) ..add(DiagnosticsProperty('onPress', onPress)) ..add(DiagnosticsProperty('onLongPress', onLongPress)); diff --git a/forui/lib/src/widgets/calendar/calendar_controller.dart b/forui/lib/src/widgets/calendar/calendar_controller.dart new file mode 100644 index 000000000..a92fab906 --- /dev/null +++ b/forui/lib/src/widgets/calendar/calendar_controller.dart @@ -0,0 +1,105 @@ +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; +import 'package:sugar/sugar.dart'; + +/// A controller that controls date selection in a calendar. +abstract class FCalendarController extends ValueNotifier { + /// Creates a [FCalendarController] with the given initial [value]. + FCalendarController(super._value); + + /// Called when the given [date] in a [FCalendarPickerType.day] picker is pressed. + /// + /// [date] is always in UTC timezone and truncated to the nearest date. + void onPress(DateTime date); + + /// Returns true if the given [date] is selected. + bool contains(DateTime date); +} + +/// A date selection controller that allows only a single date to be selected. +/// +/// The selected date is always in UTC timezone and truncated to the nearest date. +final class FCalendarSingleValueController extends FCalendarController { + /// Creates a [FCalendarSingleValueController] with the given initial [value]. + /// + /// ## Contract: + /// Throws an [AssertionError] if the given [value] is not in UTC timezone. + FCalendarSingleValueController([super.value]) : assert(value?.isUtc ?? true, 'value must be in UTC timezone'); + + @override + bool contains(DateTime date) => value?.toLocalDate() == date.toLocalDate(); + + @override + void onPress(DateTime date) { + if (value?.toLocalDate() == date.toLocalDate()) { + value = null; + } else { + value = date; + } + } +} + +/// A date selection controller that allows multiple dates to be selected. +/// +/// The selected dates are always in UTC timezone and truncated to the nearest date. +final class FCalendarMultiValueController extends FCalendarController> { + /// Creates a [FCalendarMultiValueController] with the given initial [value]. + /// + /// ## Contract: + /// Throws an [AssertionError] if the given dates in [value] is not in UTC timezone. + FCalendarMultiValueController([super.value = const {}]) : assert(value.every((d) => d.isUtc), 'dates must be in UTC timezone'); + + @override + bool contains(DateTime date) => value.contains(date); + + @override + void onPress(DateTime date) { + final copy = { ...value }; + value = copy..toggle(date); + } +} + +/// A date selection controller that allows a single range to be selected. +/// +/// Both the start and end dates of the range is inclusive. The selected dates are always in UTC timezone and truncated +/// to the nearest date. +final class FCalendarSingleRangeController extends FCalendarController<(DateTime, DateTime)?> { + /// Creates a [FCalendarSingleRangeController] with the given initial [value]. + /// + /// ## Contract: + /// Throws an [AssertionError] if the given [value] is not in UTC timezone. + FCalendarSingleRangeController([super.value]) + : assert(value == null || (value.$1.isUtc && value.$2.isUtc), 'value must be in UTC timezone'); + + @override + bool contains(DateTime date) { + if (value case (final first, final last)) { + final current = date.toLocalDate(); + return first.toLocalDate() <= current && current <= last.toLocalDate(); + } + + return false; + } + + @override + void onPress(DateTime date) { + if (value == null) { + value = (date, date); + return; + } + + final (first, last) = value!; + final pressed = date.toLocalDate(); + + switch ((first.toLocalDate(), last.toLocalDate())) { + case (final first, final last) when pressed == first || pressed == last: + value = null; + + case (final first, final last) when pressed < first: + value = (pressed.toNative(), last.toNative()); + + case (final first, _): + value = (first.toNative(), pressed.toNative()); + } + } +}