1094 lines
36 KiB
Dart
1094 lines
36 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:sticky_headers/sticky_headers.dart';
|
|
|
|
///自定义日期区间选择
|
|
class CustomCalendarRangePickerWidget extends StatefulWidget {
|
|
CustomCalendarRangePickerWidget(
|
|
{Key? key,
|
|
DateTime? initialStartDate,
|
|
DateTime? initialEndDate,
|
|
required DateTime firstDate,
|
|
required DateTime lastDate,
|
|
required this.onDateSelected,
|
|
DateTime? currentDate,
|
|
this.selectedDateDecoration,
|
|
this.currentDateDecoration,
|
|
this.rangeBackgroundColor,
|
|
this.weekendTextStyle,
|
|
this.weekTextStyle,
|
|
this.yearTextStyle})
|
|
: initialStartDate = initialStartDate != null
|
|
? DateUtils.dateOnly(initialStartDate)
|
|
: null,
|
|
initialEndDate =
|
|
initialEndDate != null ? DateUtils.dateOnly(initialEndDate) : null,
|
|
assert(firstDate != null),
|
|
assert(lastDate != null),
|
|
firstDate = DateUtils.dateOnly(firstDate),
|
|
lastDate = DateUtils.dateOnly(lastDate),
|
|
currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()),
|
|
super(key: key) {
|
|
assert(
|
|
this.initialStartDate == null ||
|
|
this.initialEndDate == null ||
|
|
!this.initialStartDate!.isAfter(initialEndDate!),
|
|
'initialStartDate must be on or before initialEndDate.',
|
|
);
|
|
assert(
|
|
!this.lastDate.isBefore(this.firstDate),
|
|
'firstDate must be on or before lastDate.',
|
|
);
|
|
}
|
|
|
|
final DateTime? initialStartDate;
|
|
|
|
final DateTime? initialEndDate;
|
|
|
|
final DateTime firstDate;
|
|
|
|
final DateTime lastDate;
|
|
|
|
final DateTime currentDate;
|
|
|
|
final Function(DateTime? startDate, DateTime? endDate) onDateSelected;
|
|
|
|
///开始时间和结束时间按钮样式
|
|
final BoxDecoration? selectedDateDecoration;
|
|
|
|
///当前时间样式
|
|
final BoxDecoration? currentDateDecoration;
|
|
|
|
///选中时间区间的背景颜色
|
|
final Color? rangeBackgroundColor;
|
|
|
|
///周六周天字体颜色
|
|
final TextStyle? weekendTextStyle;
|
|
|
|
///周字体颜色
|
|
final TextStyle? weekTextStyle;
|
|
|
|
///年月的字体
|
|
final TextStyle? yearTextStyle;
|
|
|
|
@override
|
|
_CustomCalendarRangePickerWidgetState createState() =>
|
|
_CustomCalendarRangePickerWidgetState();
|
|
}
|
|
|
|
class _CustomCalendarRangePickerWidgetState
|
|
extends State<CustomCalendarRangePickerWidget> {
|
|
final GlobalKey _scrollViewKey = GlobalKey();
|
|
DateTime? _startDate;
|
|
DateTime? _endDate;
|
|
int _initialMonthIndex = 0;
|
|
late ScrollController _controller;
|
|
late bool _showWeekBottomDivider;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = ScrollController();
|
|
_controller.addListener(_scrollListener);
|
|
|
|
_startDate = widget.initialStartDate;
|
|
_endDate = widget.initialEndDate;
|
|
|
|
// Calculate the index for the initially displayed month. This is needed to
|
|
// divide the list of months into two `SliverList`s.
|
|
final DateTime initialDate = widget.initialStartDate ?? widget.currentDate;
|
|
if (!initialDate.isBefore(widget.firstDate) &&
|
|
!initialDate.isAfter(widget.lastDate)) {
|
|
_initialMonthIndex = DateUtils.monthDelta(widget.firstDate, initialDate);
|
|
}
|
|
|
|
_showWeekBottomDivider = _initialMonthIndex != 0;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scrollListener() {
|
|
if (_controller.offset <= _controller.position.minScrollExtent) {
|
|
setState(() {
|
|
_showWeekBottomDivider = false;
|
|
});
|
|
} else if (!_showWeekBottomDivider) {
|
|
setState(() {
|
|
_showWeekBottomDivider = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
int get _numberOfMonths =>
|
|
DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1;
|
|
|
|
void _vibrate() {
|
|
switch (Theme.of(context).platform) {
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
HapticFeedback.vibrate();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// This updates the selected date range using this logic:
|
|
//
|
|
// * From the unselected state, selecting one date creates the start date.
|
|
// * If the next selection is before the start date, reset date range and
|
|
// set the start date to that selection.
|
|
// * If the next selection is on or after the start date, set the end date
|
|
// to that selection.
|
|
// * After both start and end dates are selected, any subsequent selection
|
|
// resets the date range and sets start date to that selection.
|
|
void _updateSelection(DateTime date) {
|
|
_vibrate();
|
|
setState(() {
|
|
if (_startDate != null &&
|
|
_endDate == null &&
|
|
!date.isBefore(_startDate!)) {
|
|
_endDate = date;
|
|
} else {
|
|
_startDate = date;
|
|
if (_endDate != null) {
|
|
_endDate = null;
|
|
}
|
|
}
|
|
});
|
|
widget.onDateSelected(_startDate ?? null, _endDate ?? null);
|
|
}
|
|
|
|
Widget _buildMonthItem(
|
|
BuildContext context, int index, bool beforeInitialMonth) {
|
|
final int monthIndex = beforeInitialMonth
|
|
? _initialMonthIndex - index - 1
|
|
: _initialMonthIndex + index;
|
|
final DateTime month =
|
|
DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex);
|
|
return _MonthItem(
|
|
selectedDateStart: _startDate,
|
|
selectedDateEnd: _endDate,
|
|
currentDate: widget.currentDate,
|
|
firstDate: widget.firstDate,
|
|
lastDate: widget.lastDate,
|
|
displayedMonth: month,
|
|
onChanged: _updateSelection,
|
|
selectedDateDecoration: widget.selectedDateDecoration,
|
|
currentDateDecoration: widget.currentDateDecoration,
|
|
rangeBackgroundColor: widget.rangeBackgroundColor);
|
|
}
|
|
|
|
///年月展示
|
|
Widget _yearMonthWidget(int index, bool beforeInitialMonth) {
|
|
final int monthIndex = beforeInitialMonth
|
|
? _initialMonthIndex - index - 1
|
|
: _initialMonthIndex + index;
|
|
final DateTime month =
|
|
DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex);
|
|
final MaterialLocalizations localizations =
|
|
MaterialLocalizations.of(context);
|
|
final ThemeData themeData = Theme.of(context);
|
|
final TextTheme textTheme = themeData.textTheme;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 7),
|
|
alignment: AlignmentDirectional.centerStart,
|
|
color: const Color(0xffF8F8F8),
|
|
child: ExcludeSemantics(
|
|
child: Text(
|
|
localizations.formatMonthYear(month),
|
|
style: widget.yearTextStyle ??
|
|
textTheme.bodyMedium
|
|
?.apply(color: themeData.colorScheme.onSurface),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const Key sliverAfterKey = Key('sliverAfterKey');
|
|
|
|
return Container(
|
|
color: Colors.white,
|
|
child: Column(
|
|
children: <Widget>[
|
|
_DayHeaders(
|
|
weekendTextStyle: widget.weekendTextStyle,
|
|
weekTextStyle: widget.weekTextStyle,
|
|
),
|
|
if (_showWeekBottomDivider) const Divider(height: 0),
|
|
Expanded(
|
|
child: _CalendarKeyboardNavigator(
|
|
firstDate: widget.firstDate,
|
|
lastDate: widget.lastDate,
|
|
initialFocusedDay:
|
|
_startDate ?? widget.initialStartDate ?? widget.currentDate,
|
|
// In order to prevent performance issues when displaying the
|
|
// correct initial month, 2 `SliverList`s are used to split the
|
|
// months. The first item in the second SliverList is the initial
|
|
// month to be displayed.
|
|
child: CustomScrollView(
|
|
key: _scrollViewKey,
|
|
controller: _controller,
|
|
center: sliverAfterKey,
|
|
slivers: <Widget>[
|
|
SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(BuildContext context, int index) => StickyHeader(
|
|
header: _yearMonthWidget(index, true),
|
|
content: _buildMonthItem(context, index, true),
|
|
),
|
|
childCount: _initialMonthIndex,
|
|
),
|
|
),
|
|
SliverList(
|
|
key: sliverAfterKey,
|
|
delegate: SliverChildBuilderDelegate(
|
|
(BuildContext context, int index) => StickyHeader(
|
|
header: _yearMonthWidget(index, false),
|
|
content: _buildMonthItem(context, index, false),
|
|
),
|
|
childCount: _numberOfMonths - _initialMonthIndex,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
///月份选择
|
|
class _MonthItem extends StatefulWidget {
|
|
_MonthItem(
|
|
{Key? key,
|
|
required this.selectedDateStart,
|
|
required this.selectedDateEnd,
|
|
required this.currentDate,
|
|
required this.onChanged,
|
|
required this.firstDate,
|
|
required this.lastDate,
|
|
required this.displayedMonth,
|
|
this.dragStartBehavior = DragStartBehavior.start,
|
|
this.selectedDateDecoration,
|
|
this.currentDateDecoration,
|
|
this.rangeBackgroundColor})
|
|
: assert(firstDate != null),
|
|
assert(lastDate != null),
|
|
assert(!firstDate.isAfter(lastDate)),
|
|
assert(selectedDateStart == null ||
|
|
!selectedDateStart.isBefore(firstDate)),
|
|
assert(selectedDateEnd == null || !selectedDateEnd.isBefore(firstDate)),
|
|
assert(
|
|
selectedDateStart == null || !selectedDateStart.isAfter(lastDate)),
|
|
assert(selectedDateEnd == null || !selectedDateEnd.isAfter(lastDate)),
|
|
assert(selectedDateStart == null ||
|
|
selectedDateEnd == null ||
|
|
!selectedDateStart.isAfter(selectedDateEnd)),
|
|
assert(currentDate != null),
|
|
assert(onChanged != null),
|
|
assert(displayedMonth != null),
|
|
assert(dragStartBehavior != null),
|
|
super(key: key);
|
|
|
|
/// The currently selected start date.
|
|
///
|
|
/// This date is highlighted in the picker.
|
|
final DateTime? selectedDateStart;
|
|
|
|
/// The currently selected end date.
|
|
///
|
|
/// This date is highlighted in the picker.
|
|
final DateTime? selectedDateEnd;
|
|
|
|
/// The current date at the time the picker is displayed.
|
|
final DateTime currentDate;
|
|
|
|
/// Called when the user picks a day.
|
|
final ValueChanged<DateTime> onChanged;
|
|
|
|
/// The earliest date the user is permitted to pick.
|
|
final DateTime firstDate;
|
|
|
|
/// The latest date the user is permitted to pick.
|
|
final DateTime lastDate;
|
|
|
|
/// The month whose days are displayed by this picker.
|
|
final DateTime displayedMonth;
|
|
|
|
/// Determines the way that drag start behavior is handled.
|
|
///
|
|
/// If set to [DragStartBehavior.start], the drag gesture used to scroll a
|
|
/// date picker wheel will begin upon the detection of a drag gesture. If set
|
|
/// to [DragStartBehavior.down] it will begin when a down event is first
|
|
/// detected.
|
|
///
|
|
/// In general, setting this to [DragStartBehavior.start] will make drag
|
|
/// animation smoother and setting it to [DragStartBehavior.down] will make
|
|
/// drag behavior feel slightly more reactive.
|
|
///
|
|
/// By default, the drag start behavior is [DragStartBehavior.start].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [DragGestureRecognizer.dragStartBehavior], which gives an example for
|
|
/// the different behaviors.
|
|
final DragStartBehavior dragStartBehavior;
|
|
|
|
final BoxDecoration? selectedDateDecoration;
|
|
|
|
final BoxDecoration? currentDateDecoration;
|
|
|
|
final Color? rangeBackgroundColor;
|
|
|
|
@override
|
|
_MonthItemState createState() => _MonthItemState();
|
|
}
|
|
|
|
class _MonthItemState extends State<_MonthItem> {
|
|
/// List of [FocusNode]s, one for each day of the month.
|
|
late List<FocusNode> _dayFocusNodes;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final int daysInMonth = DateUtils.getDaysInMonth(
|
|
widget.displayedMonth.year, widget.displayedMonth.month);
|
|
_dayFocusNodes = List<FocusNode>.generate(
|
|
daysInMonth,
|
|
(int index) =>
|
|
FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// Check to see if the focused date is in this month, if so focus it.
|
|
final DateTime? focusedDate = _FocusedDate.of(context)?.date;
|
|
if (focusedDate != null &&
|
|
DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) {
|
|
_dayFocusNodes[focusedDate.day - 1].requestFocus();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final FocusNode node in _dayFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Color _highlightColor(BuildContext context) {
|
|
return widget.rangeBackgroundColor ??
|
|
Theme.of(context).colorScheme.primary.withOpacity(0.12);
|
|
}
|
|
|
|
void _dayFocusChanged(bool focused) {
|
|
if (focused) {
|
|
final TraversalDirection? focusDirection =
|
|
_FocusedDate.of(context)?.scrollDirection;
|
|
if (focusDirection != null) {
|
|
ScrollPositionAlignmentPolicy policy =
|
|
ScrollPositionAlignmentPolicy.explicit;
|
|
switch (focusDirection) {
|
|
case TraversalDirection.up:
|
|
case TraversalDirection.left:
|
|
policy = ScrollPositionAlignmentPolicy.keepVisibleAtStart;
|
|
break;
|
|
case TraversalDirection.right:
|
|
case TraversalDirection.down:
|
|
policy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd;
|
|
break;
|
|
}
|
|
Scrollable.ensureVisible(
|
|
primaryFocus!.context!,
|
|
duration: _monthScrollDuration,
|
|
alignmentPolicy: policy,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildDayItem(BuildContext context, DateTime dayToBuild,
|
|
int firstDayOffset, int daysInMonth) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final ColorScheme colorScheme = theme.colorScheme;
|
|
final TextTheme textTheme = theme.textTheme;
|
|
final MaterialLocalizations localizations =
|
|
MaterialLocalizations.of(context);
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
final Color highlightColor = _highlightColor(context);
|
|
final int day = dayToBuild.day;
|
|
|
|
final bool isDisabled = dayToBuild.isAfter(widget.lastDate) ||
|
|
dayToBuild.isBefore(widget.firstDate);
|
|
|
|
BoxDecoration? decoration;
|
|
TextStyle? itemStyle = textTheme.bodyMedium;
|
|
|
|
final bool isRangeSelected =
|
|
widget.selectedDateStart != null && widget.selectedDateEnd != null;
|
|
final bool isSelectedDayStart = widget.selectedDateStart != null &&
|
|
dayToBuild.isAtSameMomentAs(widget.selectedDateStart!);
|
|
final bool isSelectedDayEnd = widget.selectedDateEnd != null &&
|
|
dayToBuild.isAtSameMomentAs(widget.selectedDateEnd!);
|
|
final bool isInRange = isRangeSelected &&
|
|
dayToBuild.isAfter(widget.selectedDateStart!) &&
|
|
dayToBuild.isBefore(widget.selectedDateEnd!);
|
|
|
|
_HighlightPainter? highlightPainter;
|
|
|
|
if (isSelectedDayStart || isSelectedDayEnd) {
|
|
// The selected start and end dates gets a circle background
|
|
// highlight, and a contrasting text color.
|
|
itemStyle = textTheme.bodyMedium?.apply(color: colorScheme.onPrimary);
|
|
decoration = widget.selectedDateDecoration ??
|
|
BoxDecoration(
|
|
color: colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
);
|
|
|
|
if (isRangeSelected &&
|
|
widget.selectedDateStart != widget.selectedDateEnd) {
|
|
final _HighlightPainterStyle style = isSelectedDayStart
|
|
? _HighlightPainterStyle.highlightTrailing
|
|
: _HighlightPainterStyle.highlightLeading;
|
|
highlightPainter = _HighlightPainter(
|
|
color: highlightColor,
|
|
style: style,
|
|
textDirection: textDirection,
|
|
);
|
|
}
|
|
} else if (isInRange) {
|
|
// The days within the range get a light background highlight.
|
|
highlightPainter = _HighlightPainter(
|
|
color: highlightColor,
|
|
style: _HighlightPainterStyle.highlightAll,
|
|
textDirection: textDirection,
|
|
);
|
|
} else if (isDisabled) {
|
|
itemStyle = textTheme.bodyMedium
|
|
?.apply(color: colorScheme.onSurface.withOpacity(0.38));
|
|
} else if (DateUtils.isSameDay(widget.currentDate, dayToBuild)) {
|
|
// The current day gets a different text color and a circle stroke
|
|
// border.
|
|
itemStyle = textTheme.bodyMedium?.apply(color: const Color(0xff4D6FD5));
|
|
decoration = widget.currentDateDecoration ??
|
|
BoxDecoration(
|
|
border: Border.all(color: const Color(0xff4D6FD5), width: 1),
|
|
shape: BoxShape.circle,
|
|
);
|
|
}
|
|
|
|
// We want the day of month to be spoken first irrespective of the
|
|
// locale-specific preferences or TextDirection. This is because
|
|
// an accessibility user is more likely to be interested in the
|
|
// day of month before the rest of the date, as they are looking
|
|
// for the day of month. To do that we prepend day of month to the
|
|
// formatted full date.
|
|
String semanticLabel =
|
|
'${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}';
|
|
if (isSelectedDayStart) {
|
|
semanticLabel =
|
|
localizations.dateRangeStartDateSemanticLabel(semanticLabel);
|
|
} else if (isSelectedDayEnd) {
|
|
semanticLabel =
|
|
localizations.dateRangeEndDateSemanticLabel(semanticLabel);
|
|
}
|
|
|
|
Widget dayWidget = Container(
|
|
decoration: decoration,
|
|
child: Center(
|
|
child: Semantics(
|
|
label: semanticLabel,
|
|
selected: isSelectedDayStart || isSelectedDayEnd,
|
|
child: ExcludeSemantics(
|
|
child: Text(localizations.formatDecimal(day), style: itemStyle),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (highlightPainter != null) {
|
|
dayWidget = CustomPaint(
|
|
painter: highlightPainter,
|
|
child: dayWidget,
|
|
);
|
|
}
|
|
|
|
if (!isDisabled) {
|
|
dayWidget = InkResponse(
|
|
focusNode: _dayFocusNodes[day - 1],
|
|
onTap: () => widget.onChanged(dayToBuild),
|
|
radius: _monthItemRowHeight / 2 + 4,
|
|
splashColor: colorScheme.primary.withOpacity(0.38),
|
|
onFocusChange: _dayFocusChanged,
|
|
child: dayWidget,
|
|
);
|
|
}
|
|
|
|
return dayWidget;
|
|
}
|
|
|
|
Widget _buildEdgeContainer(BuildContext context, bool isHighlighted) {
|
|
return Container(color: isHighlighted ? _highlightColor(context) : null);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData themeData = Theme.of(context);
|
|
final TextTheme textTheme = themeData.textTheme;
|
|
final MaterialLocalizations localizations =
|
|
MaterialLocalizations.of(context);
|
|
final int year = widget.displayedMonth.year;
|
|
final int month = widget.displayedMonth.month;
|
|
final int daysInMonth = DateUtils.getDaysInMonth(year, month);
|
|
final int dayOffset = DateUtils.firstDayOffset(year, month, localizations);
|
|
final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil();
|
|
final double gridHeight =
|
|
weeks * _monthItemRowHeight + (weeks - 1) * _monthItemSpaceBetweenRows;
|
|
final List<Widget> dayItems = <Widget>[];
|
|
|
|
for (int i = 0; true; i += 1) {
|
|
// 1-based day of month, e.g. 1-31 for January, and 1-29 for February on
|
|
// a leap year.
|
|
final int day = i - dayOffset + 1;
|
|
if (day > daysInMonth) break;
|
|
if (day < 1) {
|
|
dayItems.add(Container());
|
|
} else {
|
|
final DateTime dayToBuild = DateTime(year, month, day);
|
|
final Widget dayItem = _buildDayItem(
|
|
context,
|
|
dayToBuild,
|
|
dayOffset,
|
|
daysInMonth,
|
|
);
|
|
dayItems.add(dayItem);
|
|
}
|
|
}
|
|
|
|
// Add the leading/trailing edge containers to each week in order to
|
|
// correctly extend the range highlight.
|
|
final List<Widget> paddedDayItems = <Widget>[];
|
|
for (int i = 0; i < weeks; i++) {
|
|
final int start = i * DateTime.daysPerWeek;
|
|
final int end = math.min(
|
|
start + DateTime.daysPerWeek,
|
|
dayItems.length,
|
|
);
|
|
final List<Widget> weekList = dayItems.sublist(start, end);
|
|
|
|
final DateTime dateAfterLeadingPadding =
|
|
DateTime(year, month, start - dayOffset + 1);
|
|
// Only color the edge container if it is after the start date and
|
|
// on/before the end date.
|
|
final bool isLeadingInRange = !(dayOffset > 0 && i == 0) &&
|
|
widget.selectedDateStart != null &&
|
|
widget.selectedDateEnd != null &&
|
|
dateAfterLeadingPadding.isAfter(widget.selectedDateStart!) &&
|
|
!dateAfterLeadingPadding.isAfter(widget.selectedDateEnd!);
|
|
weekList.insert(0, _buildEdgeContainer(context, isLeadingInRange));
|
|
|
|
// Only add a trailing edge container if it is for a full week and not a
|
|
// partial week.
|
|
if (end < dayItems.length ||
|
|
(end == dayItems.length &&
|
|
dayItems.length % DateTime.daysPerWeek == 0)) {
|
|
final DateTime dateBeforeTrailingPadding =
|
|
DateTime(year, month, end - dayOffset);
|
|
// Only color the edge container if it is on/after the start date and
|
|
// before the end date.
|
|
final bool isTrailingInRange = widget.selectedDateStart != null &&
|
|
widget.selectedDateEnd != null &&
|
|
!dateBeforeTrailingPadding.isBefore(widget.selectedDateStart!) &&
|
|
dateBeforeTrailingPadding.isBefore(widget.selectedDateEnd!);
|
|
weekList.add(_buildEdgeContainer(context, isTrailingInRange));
|
|
}
|
|
|
|
paddedDayItems.addAll(weekList);
|
|
}
|
|
|
|
final double maxWidth =
|
|
MediaQuery.of(context).orientation == Orientation.landscape
|
|
? _maxCalendarWidthLandscape
|
|
: _maxCalendarWidthPortrait;
|
|
return Column(
|
|
children: <Widget>[
|
|
Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: maxWidth,
|
|
maxHeight: gridHeight,
|
|
),
|
|
child: GridView.custom(
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: _monthItemGridDelegate,
|
|
padding: EdgeInsets.zero,
|
|
childrenDelegate: SliverChildListDelegate(
|
|
paddedDayItems,
|
|
addRepaintBoundaries: false,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: _monthItemFooterHeight),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
///变量
|
|
const Duration _monthScrollDuration = Duration(milliseconds: 200);
|
|
|
|
const double _monthItemFooterHeight = 12.0;
|
|
const double _monthItemRowHeight = 42.0;
|
|
const double _monthItemSpaceBetweenRows = 8.0;
|
|
const double _horizontalPadding = 8.0;
|
|
const double _maxCalendarWidthLandscape = 384.0;
|
|
const double _maxCalendarWidthPortrait = 480.0;
|
|
|
|
class _FocusedDate extends InheritedWidget {
|
|
const _FocusedDate({
|
|
Key? key,
|
|
required Widget child,
|
|
this.date,
|
|
this.scrollDirection,
|
|
}) : super(key: key, child: child);
|
|
|
|
final DateTime? date;
|
|
final TraversalDirection? scrollDirection;
|
|
|
|
@override
|
|
bool updateShouldNotify(_FocusedDate oldWidget) {
|
|
return !DateUtils.isSameDay(date, oldWidget.date) ||
|
|
scrollDirection != oldWidget.scrollDirection;
|
|
}
|
|
|
|
static _FocusedDate? of(BuildContext context) {
|
|
return context.dependOnInheritedWidgetOfExactType<_FocusedDate>();
|
|
}
|
|
}
|
|
|
|
///日期格式
|
|
class _MonthItemGridDelegate extends SliverGridDelegate {
|
|
const _MonthItemGridDelegate();
|
|
|
|
@override
|
|
SliverGridLayout getLayout(SliverConstraints constraints) {
|
|
final double tileWidth =
|
|
(constraints.crossAxisExtent - 2 * _horizontalPadding) /
|
|
DateTime.daysPerWeek;
|
|
return _MonthSliverGridLayout(
|
|
crossAxisCount: DateTime.daysPerWeek + 2,
|
|
dayChildWidth: tileWidth,
|
|
edgeChildWidth: _horizontalPadding,
|
|
reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRelayout(_MonthItemGridDelegate oldDelegate) => false;
|
|
}
|
|
|
|
const _MonthItemGridDelegate _monthItemGridDelegate = _MonthItemGridDelegate();
|
|
|
|
class _MonthSliverGridLayout extends SliverGridLayout {
|
|
/// Creates a layout that uses equally sized and spaced tiles for each day of
|
|
/// the week and an additional edge tile for padding at the start and end of
|
|
/// each row.
|
|
///
|
|
/// This is necessary to facilitate the painting of the range highlight
|
|
/// correctly.
|
|
const _MonthSliverGridLayout({
|
|
required this.crossAxisCount,
|
|
required this.dayChildWidth,
|
|
required this.edgeChildWidth,
|
|
required this.reverseCrossAxis,
|
|
}) : assert(crossAxisCount != null && crossAxisCount > 0),
|
|
assert(dayChildWidth != null && dayChildWidth >= 0),
|
|
assert(edgeChildWidth != null && edgeChildWidth >= 0),
|
|
assert(reverseCrossAxis != null);
|
|
|
|
/// The number of children in the cross axis.
|
|
final int crossAxisCount;
|
|
|
|
/// The width in logical pixels of the day child widgets.
|
|
final double dayChildWidth;
|
|
|
|
/// The width in logical pixels of the edge child widgets.
|
|
final double edgeChildWidth;
|
|
|
|
/// Whether the children should be placed in the opposite order of increasing
|
|
/// coordinates in the cross axis.
|
|
///
|
|
/// For example, if the cross axis is horizontal, the children are placed from
|
|
/// left to right when [reverseCrossAxis] is false and from right to left when
|
|
/// [reverseCrossAxis] is true.
|
|
///
|
|
/// Typically set to the return value of [axisDirectionIsReversed] applied to
|
|
/// the [SliverConstraints.crossAxisDirection].
|
|
final bool reverseCrossAxis;
|
|
|
|
/// The number of logical pixels from the leading edge of one row to the
|
|
/// leading edge of the next row.
|
|
double get _rowHeight {
|
|
return _monthItemRowHeight + _monthItemSpaceBetweenRows;
|
|
}
|
|
|
|
/// The height in logical pixels of the children widgets.
|
|
double get _childHeight {
|
|
return _monthItemRowHeight;
|
|
}
|
|
|
|
@override
|
|
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
|
return crossAxisCount * (scrollOffset ~/ _rowHeight);
|
|
}
|
|
|
|
@override
|
|
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
|
final int mainAxisCount = (scrollOffset / _rowHeight).ceil();
|
|
return math.max(0, crossAxisCount * mainAxisCount - 1);
|
|
}
|
|
|
|
double _getCrossAxisOffset(double crossAxisStart, bool isPadding) {
|
|
if (reverseCrossAxis) {
|
|
return ((crossAxisCount - 2) * dayChildWidth + 2 * edgeChildWidth) -
|
|
crossAxisStart -
|
|
(isPadding ? edgeChildWidth : dayChildWidth);
|
|
}
|
|
return crossAxisStart;
|
|
}
|
|
|
|
@override
|
|
SliverGridGeometry getGeometryForChildIndex(int index) {
|
|
final int adjustedIndex = index % crossAxisCount;
|
|
final bool isEdge =
|
|
adjustedIndex == 0 || adjustedIndex == crossAxisCount - 1;
|
|
final double crossAxisStart =
|
|
math.max(0, (adjustedIndex - 1) * dayChildWidth + edgeChildWidth);
|
|
|
|
return SliverGridGeometry(
|
|
scrollOffset: (index ~/ crossAxisCount) * _rowHeight,
|
|
crossAxisOffset: _getCrossAxisOffset(crossAxisStart, isEdge),
|
|
mainAxisExtent: _childHeight,
|
|
crossAxisExtent: isEdge ? edgeChildWidth : dayChildWidth,
|
|
);
|
|
}
|
|
|
|
@override
|
|
double computeMaxScrollOffset(int childCount) {
|
|
assert(childCount >= 0);
|
|
final int mainAxisCount = ((childCount - 1) ~/ crossAxisCount) + 1;
|
|
final double mainAxisSpacing = _rowHeight - _childHeight;
|
|
return _rowHeight * mainAxisCount - mainAxisSpacing;
|
|
}
|
|
}
|
|
|
|
///星期显示
|
|
class _DayHeaders extends StatelessWidget {
|
|
/// Builds widgets showing abbreviated days of week. The first widget in the
|
|
/// returned list corresponds to the first day of week for the current locale.
|
|
///
|
|
/// Examples:
|
|
///
|
|
/// ```
|
|
/// ┌ Sunday is the first day of week in the US (en_US)
|
|
/// |
|
|
/// S M T W T F S <-- the returned list contains these widgets
|
|
/// _ _ _ _ _ 1 2
|
|
/// 3 4 5 6 7 8 9
|
|
///
|
|
/// ┌ But it's Monday in the UK (en_GB)
|
|
/// |
|
|
/// M T W T F S S <-- the returned list contains these widgets
|
|
/// _ _ _ _ 1 2 3
|
|
/// 4 5 6 7 8 9 10
|
|
/// ```
|
|
|
|
_DayHeaders({this.weekTextStyle, this.weekendTextStyle});
|
|
|
|
///周六周天字体颜色
|
|
final TextStyle? weekendTextStyle;
|
|
|
|
///周字体颜色
|
|
final TextStyle? weekTextStyle;
|
|
|
|
List<Widget> _getDayHeaders(
|
|
TextStyle headerStyle, MaterialLocalizations localizations) {
|
|
final List<Widget> result = <Widget>[];
|
|
for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) {
|
|
final String weekday = localizations.narrowWeekdays[i];
|
|
result.add(ExcludeSemantics(
|
|
child: Center(
|
|
child: Text(weekday,
|
|
style: i == 0 || i == 6
|
|
? weekendTextStyle ?? weekTextStyle
|
|
: headerStyle)),
|
|
));
|
|
if (i == (localizations.firstDayOfWeekIndex - 1) % 7) break;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData themeData = Theme.of(context);
|
|
final ColorScheme colorScheme = themeData.colorScheme;
|
|
final TextStyle textStyle = weekTextStyle ??
|
|
themeData.textTheme.bodyMedium!.apply(color: colorScheme.onSurface);
|
|
final MaterialLocalizations localizations =
|
|
MaterialLocalizations.of(context);
|
|
final List<Widget> labels = _getDayHeaders(textStyle, localizations);
|
|
|
|
// Add leading and trailing containers for edges of the custom grid layout.
|
|
labels.insert(0, Container());
|
|
labels.add(Container());
|
|
|
|
return Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).orientation == Orientation.landscape
|
|
? _maxCalendarWidthLandscape
|
|
: _maxCalendarWidthPortrait,
|
|
maxHeight: _monthItemRowHeight,
|
|
),
|
|
child: GridView.custom(
|
|
shrinkWrap: true,
|
|
gridDelegate: _monthItemGridDelegate,
|
|
childrenDelegate: SliverChildListDelegate(
|
|
labels,
|
|
addRepaintBoundaries: false,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
///日期滚动控制组件
|
|
class _CalendarKeyboardNavigator extends StatefulWidget {
|
|
const _CalendarKeyboardNavigator({
|
|
Key? key,
|
|
required this.child,
|
|
required this.firstDate,
|
|
required this.lastDate,
|
|
required this.initialFocusedDay,
|
|
}) : super(key: key);
|
|
|
|
final Widget child;
|
|
final DateTime firstDate;
|
|
final DateTime lastDate;
|
|
final DateTime initialFocusedDay;
|
|
|
|
@override
|
|
_CalendarKeyboardNavigatorState createState() =>
|
|
_CalendarKeyboardNavigatorState();
|
|
}
|
|
|
|
class _CalendarKeyboardNavigatorState
|
|
extends State<_CalendarKeyboardNavigator> {
|
|
late Map<LogicalKeySet, Intent> _shortcutMap;
|
|
late Map<Type, Action<Intent>> _actionMap;
|
|
late FocusNode _dayGridFocus;
|
|
TraversalDirection? _dayTraversalDirection;
|
|
DateTime? _focusedDay;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_shortcutMap = <LogicalKeySet, Intent>{
|
|
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
|
|
const DirectionalFocusIntent(TraversalDirection.left),
|
|
LogicalKeySet(LogicalKeyboardKey.arrowRight):
|
|
const DirectionalFocusIntent(TraversalDirection.right),
|
|
LogicalKeySet(LogicalKeyboardKey.arrowDown):
|
|
const DirectionalFocusIntent(TraversalDirection.down),
|
|
LogicalKeySet(LogicalKeyboardKey.arrowUp):
|
|
const DirectionalFocusIntent(TraversalDirection.up),
|
|
};
|
|
_actionMap = <Type, Action<Intent>>{
|
|
NextFocusIntent:
|
|
CallbackAction<NextFocusIntent>(onInvoke: _handleGridNextFocus),
|
|
PreviousFocusIntent: CallbackAction<PreviousFocusIntent>(
|
|
onInvoke: _handleGridPreviousFocus),
|
|
DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>(
|
|
onInvoke: _handleDirectionFocus),
|
|
};
|
|
_dayGridFocus = FocusNode(debugLabel: 'Day Grid');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_dayGridFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleGridFocusChange(bool focused) {
|
|
setState(() {
|
|
if (focused) {
|
|
_focusedDay ??= widget.initialFocusedDay;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Move focus to the next element after the day grid.
|
|
void _handleGridNextFocus(NextFocusIntent intent) {
|
|
_dayGridFocus.requestFocus();
|
|
_dayGridFocus.nextFocus();
|
|
}
|
|
|
|
/// Move focus to the previous element before the day grid.
|
|
void _handleGridPreviousFocus(PreviousFocusIntent intent) {
|
|
_dayGridFocus.requestFocus();
|
|
_dayGridFocus.previousFocus();
|
|
}
|
|
|
|
/// Move the internal focus date in the direction of the given intent.
|
|
///
|
|
/// This will attempt to move the focused day to the next selectable day in
|
|
/// the given direction. If the new date is not in the current month, then
|
|
/// the page view will be scrolled to show the new date's month.
|
|
///
|
|
/// For horizontal directions, it will move forward or backward a day (depending
|
|
/// on the current [TextDirection]). For vertical directions it will move up and
|
|
/// down a week at a time.
|
|
void _handleDirectionFocus(DirectionalFocusIntent intent) {
|
|
assert(_focusedDay != null);
|
|
setState(() {
|
|
final DateTime? nextDate =
|
|
_nextDateInDirection(_focusedDay!, intent.direction);
|
|
if (nextDate != null) {
|
|
_focusedDay = nextDate;
|
|
_dayTraversalDirection = intent.direction;
|
|
}
|
|
});
|
|
}
|
|
|
|
static const Map<TraversalDirection, int> _directionOffset =
|
|
<TraversalDirection, int>{
|
|
TraversalDirection.up: -DateTime.daysPerWeek,
|
|
TraversalDirection.right: 1,
|
|
TraversalDirection.down: DateTime.daysPerWeek,
|
|
TraversalDirection.left: -1,
|
|
};
|
|
|
|
int _dayDirectionOffset(
|
|
TraversalDirection traversalDirection, TextDirection textDirection) {
|
|
// Swap left and right if the text direction if RTL
|
|
if (textDirection == TextDirection.rtl) {
|
|
if (traversalDirection == TraversalDirection.left)
|
|
traversalDirection = TraversalDirection.right;
|
|
else if (traversalDirection == TraversalDirection.right)
|
|
traversalDirection = TraversalDirection.left;
|
|
}
|
|
return _directionOffset[traversalDirection]!;
|
|
}
|
|
|
|
DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) {
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
final DateTime nextDate = DateUtils.addDaysToDate(
|
|
date, _dayDirectionOffset(direction, textDirection));
|
|
if (!nextDate.isBefore(widget.firstDate) &&
|
|
!nextDate.isAfter(widget.lastDate)) {
|
|
return nextDate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FocusableActionDetector(
|
|
shortcuts: _shortcutMap,
|
|
actions: _actionMap,
|
|
focusNode: _dayGridFocus,
|
|
onFocusChange: _handleGridFocusChange,
|
|
child: _FocusedDate(
|
|
date: _dayGridFocus.hasFocus ? _focusedDay : null,
|
|
scrollDirection: _dayGridFocus.hasFocus ? _dayTraversalDirection : null,
|
|
child: widget.child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
///高度绘制
|
|
class _HighlightPainter extends CustomPainter {
|
|
_HighlightPainter({
|
|
required this.color,
|
|
this.style = _HighlightPainterStyle.none,
|
|
this.textDirection,
|
|
});
|
|
|
|
final Color color;
|
|
final _HighlightPainterStyle style;
|
|
final TextDirection? textDirection;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
if (style == _HighlightPainterStyle.none) {
|
|
return;
|
|
}
|
|
|
|
final Paint paint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.fill;
|
|
|
|
final Rect rectLeft = Rect.fromLTWH(0, 0, size.width / 2, size.height);
|
|
final Rect rectRight =
|
|
Rect.fromLTWH(size.width / 2, 0, size.width / 2, size.height);
|
|
|
|
switch (style) {
|
|
case _HighlightPainterStyle.highlightTrailing:
|
|
canvas.drawRect(
|
|
textDirection == TextDirection.ltr ? rectRight : rectLeft,
|
|
paint,
|
|
);
|
|
break;
|
|
case _HighlightPainterStyle.highlightLeading:
|
|
canvas.drawRect(
|
|
textDirection == TextDirection.ltr ? rectLeft : rectRight,
|
|
paint,
|
|
);
|
|
break;
|
|
case _HighlightPainterStyle.highlightAll:
|
|
canvas.drawRect(
|
|
Rect.fromLTWH(0, 0, size.width, size.height),
|
|
paint,
|
|
);
|
|
break;
|
|
case _HighlightPainterStyle.none:
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
enum _HighlightPainterStyle {
|
|
/// Paints nothing.
|
|
none,
|
|
|
|
/// Paints a rectangle that occupies the leading half of the space.
|
|
highlightLeading,
|
|
|
|
/// Paints a rectangle that occupies the trailing half of the space.
|
|
highlightTrailing,
|
|
|
|
/// Paints a rectangle that occupies all available space.
|
|
highlightAll,
|
|
}
|