mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Keyboard navigation for the Material Date Picker grid (#59586)
This commit is contained in:
parent
dd6dd7aef1
commit
cc36608f14
@ -283,11 +283,9 @@ class _CalendarDatePickerState extends State<CalendarDatePicker> {
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
height: _maxDayPickerHeight,
|
||||
child: _buildPicker(),
|
||||
),
|
||||
SizedBox(
|
||||
height: _subHeaderHeight + _maxDayPickerHeight,
|
||||
child: _buildPicker(),
|
||||
),
|
||||
// Put the mode toggle button on top so that it won't be covered up by the _MonthPicker
|
||||
_DatePickerModeToggleButton(
|
||||
@ -476,12 +474,17 @@ class _MonthPicker extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MonthPickerState extends State<_MonthPicker> {
|
||||
final GlobalKey _pageViewKey = GlobalKey();
|
||||
DateTime _currentMonth;
|
||||
DateTime _nextMonthDate;
|
||||
DateTime _previousMonthDate;
|
||||
PageController _pageController;
|
||||
MaterialLocalizations _localizations;
|
||||
TextDirection _textDirection;
|
||||
Map<LogicalKeySet, Intent> _shortcutMap;
|
||||
Map<Type, Action<Intent>> _actionMap;
|
||||
FocusNode _dayGridFocus;
|
||||
DateTime _focusedDay;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -490,6 +493,18 @@ class _MonthPickerState extends State<_MonthPicker> {
|
||||
_previousMonthDate = utils.addMonthsToMonthDate(_currentMonth, -1);
|
||||
_nextMonthDate = utils.addMonthsToMonthDate(_currentMonth, 1);
|
||||
_pageController = PageController(initialPage: utils.monthDelta(widget.firstDate, _currentMonth));
|
||||
_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
|
||||
@ -502,19 +517,58 @@ class _MonthPickerState extends State<_MonthPicker> {
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController?.dispose();
|
||||
_dayGridFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleMonthPageChanged(int monthPage) {
|
||||
final DateTime monthDate = utils.addMonthsToMonthDate(widget.firstDate, monthPage);
|
||||
if (_currentMonth.year != monthDate.year || _currentMonth.month != monthDate.month) {
|
||||
_currentMonth = DateTime(monthDate.year, monthDate.month);
|
||||
_previousMonthDate = utils.addMonthsToMonthDate(_currentMonth, -1);
|
||||
_nextMonthDate = utils.addMonthsToMonthDate(_currentMonth, 1);
|
||||
widget.onDisplayedMonthChanged?.call(_currentMonth);
|
||||
}
|
||||
void _handleDateSelected(DateTime selectedDate) {
|
||||
_focusedDay = selectedDate;
|
||||
widget.onChanged?.call(selectedDate);
|
||||
}
|
||||
|
||||
void _handleMonthPageChanged(int monthPage) {
|
||||
setState(() {
|
||||
final DateTime monthDate = utils.addMonthsToMonthDate(widget.firstDate, monthPage);
|
||||
if (!utils.isSameMonth(_currentMonth, monthDate)) {
|
||||
_currentMonth = DateTime(monthDate.year, monthDate.month);
|
||||
_previousMonthDate = utils.addMonthsToMonthDate(_currentMonth, -1);
|
||||
_nextMonthDate = utils.addMonthsToMonthDate(_currentMonth, 1);
|
||||
widget.onDisplayedMonthChanged?.call(_currentMonth);
|
||||
if (_focusedDay != null && !utils.isSameMonth(_focusedDay, _currentMonth)) {
|
||||
// We have navigated to a new month with the grid focused, but the
|
||||
// focused day is not in this month. Choose a new one trying to keep
|
||||
// the same day of the month.
|
||||
_focusedDay = _focusableDayForMonth(_currentMonth, _focusedDay.day);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a focusable date for the given month.
|
||||
///
|
||||
/// If the preferredDay is available in the month it will be returned,
|
||||
/// otherwise the first selectable day in the month will be returned. If
|
||||
/// no dates are selectable in the month, then it will return null.
|
||||
DateTime _focusableDayForMonth(DateTime month, int preferredDay) {
|
||||
final int daysInMonth = utils.getDaysInMonth(month.year, month.month);
|
||||
|
||||
// Can we use the preferred day in this month?
|
||||
if (preferredDay <= daysInMonth) {
|
||||
final DateTime newFocus = DateTime(month.year, month.month, preferredDay);
|
||||
if (_isSelectable(newFocus))
|
||||
return newFocus;
|
||||
}
|
||||
|
||||
// Start at the 1st and take the first selectable date.
|
||||
for (int day = 1; day <= daysInMonth; day++) {
|
||||
final DateTime newFocus = DateTime(month.year, month.month, day);
|
||||
if (_isSelectable(newFocus))
|
||||
return newFocus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Navigate to the next month.
|
||||
void _handleNextMonth() {
|
||||
if (!_isDisplayingLastMonth) {
|
||||
SemanticsService.announce(
|
||||
@ -528,6 +582,7 @@ class _MonthPickerState extends State<_MonthPicker> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the previous month.
|
||||
void _handlePreviousMonth() {
|
||||
if (!_isDisplayingFirstMonth) {
|
||||
SemanticsService.announce(
|
||||
@ -541,6 +596,15 @@ class _MonthPickerState extends State<_MonthPicker> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the given month.
|
||||
void _showMonth(DateTime month) {
|
||||
final int monthPage = utils.monthDelta(widget.firstDate, month);
|
||||
_pageController.animateToPage(monthPage,
|
||||
duration: _monthScrollDuration,
|
||||
curve: Curves.ease
|
||||
);
|
||||
}
|
||||
|
||||
/// True if the earliest allowable month is displayed.
|
||||
bool get _isDisplayingFirstMonth {
|
||||
return !_currentMonth.isAfter(
|
||||
@ -555,13 +619,96 @@ class _MonthPickerState extends State<_MonthPicker> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Handler for when the overall day grid obtains or loses focus.
|
||||
void _handleGridFocusChange(bool focused) {
|
||||
setState(() {
|
||||
if (focused && _focusedDay == null) {
|
||||
if (utils.isSameMonth(widget.selectedDate, _currentMonth)) {
|
||||
_focusedDay = widget.selectedDate;
|
||||
} else if (utils.isSameMonth(widget.currentDate, _currentMonth)) {
|
||||
_focusedDay = _focusableDayForMonth(_currentMonth, widget.currentDate.day);
|
||||
} else {
|
||||
_focusedDay = _focusableDayForMonth(_currentMonth, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
if (!utils.isSameMonth(_focusedDay, _currentMonth)) {
|
||||
_showMonth(_focusedDay);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static const Map<TraversalDirection, Duration> _directionOffset = <TraversalDirection, Duration>{
|
||||
TraversalDirection.up: Duration(days: -DateTime.daysPerWeek),
|
||||
TraversalDirection.right: Duration(days: 1),
|
||||
TraversalDirection.down: Duration(days: DateTime.daysPerWeek),
|
||||
TraversalDirection.left: Duration(days: -1),
|
||||
};
|
||||
|
||||
Duration _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);
|
||||
DateTime nextDate = date.toUtc().add(_dayDirectionOffset(direction, textDirection));
|
||||
while (!nextDate.isBefore(widget.firstDate) && !nextDate.isAfter(widget.lastDate)) {
|
||||
if (_isSelectable(nextDate)) {
|
||||
return nextDate;
|
||||
}
|
||||
nextDate = nextDate.add(_dayDirectionOffset(direction, textDirection));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isSelectable(DateTime date) {
|
||||
return widget.selectableDayPredicate == null || widget.selectableDayPredicate.call(date);
|
||||
}
|
||||
|
||||
Widget _buildItems(BuildContext context, int index) {
|
||||
final DateTime month = utils.addMonthsToMonthDate(widget.firstDate, index);
|
||||
return _DayPicker(
|
||||
key: ValueKey<DateTime>(month),
|
||||
selectedDate: widget.selectedDate,
|
||||
currentDate: widget.currentDate,
|
||||
onChanged: widget.onChanged,
|
||||
onChanged: _handleDateSelected,
|
||||
firstDate: widget.firstDate,
|
||||
lastDate: widget.lastDate,
|
||||
displayedMonth: month,
|
||||
@ -599,14 +746,26 @@ class _MonthPickerState extends State<_MonthPicker> {
|
||||
],
|
||||
),
|
||||
),
|
||||
_DayHeaders(),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemBuilder: _buildItems,
|
||||
itemCount: utils.monthDelta(widget.firstDate, widget.lastDate) + 1,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: _handleMonthPageChanged,
|
||||
child: FocusableActionDetector(
|
||||
shortcuts: _shortcutMap,
|
||||
actions: _actionMap,
|
||||
focusNode: _dayGridFocus,
|
||||
onFocusChange: _handleGridFocusChange,
|
||||
child: _FocusedDate(
|
||||
date: _dayGridFocus.hasFocus ? _focusedDay : null,
|
||||
child: Container(
|
||||
color: _dayGridFocus.hasFocus ? Theme.of(context).focusColor : null,
|
||||
child: PageView.builder(
|
||||
key: _pageViewKey,
|
||||
controller: _pageController,
|
||||
itemBuilder: _buildItems,
|
||||
itemCount: utils.monthDelta(widget.firstDate, widget.lastDate) + 1,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: _handleMonthPageChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -615,11 +774,35 @@ class _MonthPickerState extends State<_MonthPicker> {
|
||||
}
|
||||
}
|
||||
|
||||
/// InheritedWidget indicating what the current focused date is for its children.
|
||||
///
|
||||
/// This is used by the [_MonthPicker] to let its children [_DayPicker]s know
|
||||
/// what the currently focused date (if any) should be.
|
||||
class _FocusedDate extends InheritedWidget {
|
||||
const _FocusedDate({
|
||||
Key key,
|
||||
Widget child,
|
||||
this.date
|
||||
}) : super(key: key, child: child);
|
||||
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_FocusedDate oldWidget) {
|
||||
return !utils.isSameDay(date, oldWidget.date);
|
||||
}
|
||||
|
||||
static DateTime of(BuildContext context) {
|
||||
final _FocusedDate focusedDate = context.dependOnInheritedWidgetOfExactType<_FocusedDate>();
|
||||
return focusedDate?.date;
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the days of a given month and allows choosing a day.
|
||||
///
|
||||
/// The days are arranged in a rectangular grid with one column for each day of
|
||||
/// the week.
|
||||
class _DayPicker extends StatelessWidget {
|
||||
class _DayPicker extends StatefulWidget {
|
||||
/// Creates a day picker.
|
||||
_DayPicker({
|
||||
Key key,
|
||||
@ -668,11 +851,82 @@ class _DayPicker extends StatelessWidget {
|
||||
/// Optional user supplied predicate function to customize selectable days.
|
||||
final SelectableDayPredicate selectableDayPredicate;
|
||||
|
||||
@override
|
||||
_DayPickerState createState() => _DayPickerState();
|
||||
}
|
||||
|
||||
class _DayPickerState extends State<_DayPicker> {
|
||||
|
||||
/// List of [FocusNode]s, one for each day of the month.
|
||||
List<FocusNode> _dayFocusNodes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final int daysInMonth = utils.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);
|
||||
if (focusedDate != null && utils.isSameMonth(widget.displayedMonth, focusedDate)) {
|
||||
_dayFocusNodes[focusedDate.day - 1].requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final FocusNode node in _dayFocusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// ```
|
||||
List<Widget> _dayHeaders(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: headerStyle)),
|
||||
));
|
||||
if (i == (localizations.firstDayOfWeekIndex - 1) % 7)
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||
final TextTheme textTheme = Theme.of(context).textTheme;
|
||||
final TextStyle headerStyle = textTheme.caption?.apply(
|
||||
color: colorScheme.onSurface.withOpacity(0.60),
|
||||
);
|
||||
final TextStyle dayStyle = textTheme.caption;
|
||||
final Color enabledDayColor = colorScheme.onSurface.withOpacity(0.87);
|
||||
final Color disabledDayColor = colorScheme.onSurface.withOpacity(0.38);
|
||||
@ -680,13 +934,13 @@ class _DayPicker extends StatelessWidget {
|
||||
final Color selectedDayBackground = colorScheme.primary;
|
||||
final Color todayColor = colorScheme.primary;
|
||||
|
||||
final int year = displayedMonth.year;
|
||||
final int month = displayedMonth.month;
|
||||
final int year = widget.displayedMonth.year;
|
||||
final int month = widget.displayedMonth.month;
|
||||
|
||||
final int daysInMonth = utils.getDaysInMonth(year, month);
|
||||
final int dayOffset = utils.firstDayOffset(year, month, localizations);
|
||||
|
||||
final List<Widget> dayItems = <Widget>[];
|
||||
final List<Widget> dayItems = _dayHeaders(headerStyle, localizations);
|
||||
// 1-based day of month, e.g. 1-31 for January, and 1-29 for February on
|
||||
// a leap year.
|
||||
int day = -dayOffset;
|
||||
@ -696,13 +950,14 @@ class _DayPicker extends StatelessWidget {
|
||||
dayItems.add(Container());
|
||||
} else {
|
||||
final DateTime dayToBuild = DateTime(year, month, day);
|
||||
final bool isDisabled = dayToBuild.isAfter(lastDate) ||
|
||||
dayToBuild.isBefore(firstDate) ||
|
||||
(selectableDayPredicate != null && !selectableDayPredicate(dayToBuild));
|
||||
final bool isDisabled = dayToBuild.isAfter(widget.lastDate) ||
|
||||
dayToBuild.isBefore(widget.firstDate) ||
|
||||
(widget.selectableDayPredicate != null && !widget.selectableDayPredicate(dayToBuild));
|
||||
final bool isSelectedDay = utils.isSameDay(widget.selectedDate, dayToBuild);
|
||||
final bool isToday = utils.isSameDay(widget.currentDate, dayToBuild);
|
||||
|
||||
BoxDecoration decoration;
|
||||
Color dayColor = enabledDayColor;
|
||||
final bool isSelectedDay = utils.isSameDay(selectedDate, dayToBuild);
|
||||
if (isSelectedDay) {
|
||||
// The selected day gets a circle background highlight, and a
|
||||
// contrasting text color.
|
||||
@ -713,7 +968,7 @@ class _DayPicker extends StatelessWidget {
|
||||
);
|
||||
} else if (isDisabled) {
|
||||
dayColor = disabledDayColor;
|
||||
} else if (utils.isSameDay(currentDate, dayToBuild)) {
|
||||
} else if (isToday) {
|
||||
// The current day gets a different text color and a circle stroke
|
||||
// border.
|
||||
dayColor = todayColor;
|
||||
@ -735,9 +990,11 @@ class _DayPicker extends StatelessWidget {
|
||||
child: dayWidget,
|
||||
);
|
||||
} else {
|
||||
dayWidget = GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onChanged(dayToBuild),
|
||||
dayWidget = InkResponse(
|
||||
focusNode: _dayFocusNodes[day - 1],
|
||||
onTap: () => widget.onChanged(dayToBuild),
|
||||
radius: _dayPickerRowHeight / 2 + 4,
|
||||
splashColor: selectedDayBackground.withOpacity(0.38),
|
||||
child: Semantics(
|
||||
// We want the day of month to be spoken first irrespective of the
|
||||
// locale-specific preferences or TextDirection. This is because
|
||||
@ -781,7 +1038,7 @@ class _DayPickerGridDelegate extends SliverGridDelegate {
|
||||
const int columnCount = DateTime.daysPerWeek;
|
||||
final double tileWidth = constraints.crossAxisExtent / columnCount;
|
||||
final double tileHeight = math.min(_dayPickerRowHeight,
|
||||
constraints.viewportMainAxisExtent / _maxDayPickerRowCount);
|
||||
constraints.viewportMainAxisExtent / (_maxDayPickerRowCount + 1));
|
||||
return SliverGridRegularTileLayout(
|
||||
childCrossAxisExtent: tileWidth,
|
||||
childMainAxisExtent: tileHeight,
|
||||
@ -798,64 +1055,6 @@ class _DayPickerGridDelegate extends SliverGridDelegate {
|
||||
|
||||
const _DayPickerGridDelegate _dayPickerGridDelegate = _DayPickerGridDelegate();
|
||||
|
||||
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
|
||||
/// ```
|
||||
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: headerStyle)),
|
||||
));
|
||||
if (i == (localizations.firstDayOfWeekIndex - 1) % 7)
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme colorScheme = theme.colorScheme;
|
||||
final TextStyle dayHeaderStyle = theme.textTheme.caption?.apply(
|
||||
color: colorScheme.onSurface.withOpacity(0.60),
|
||||
);
|
||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||
final List<Widget> labels = _getDayHeaders(dayHeaderStyle, localizations);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _monthPickerHorizontalPadding,
|
||||
),
|
||||
child: GridView.custom(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: _dayPickerGridDelegate,
|
||||
childrenDelegate: SliverChildListDelegate(
|
||||
labels,
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A scrollable list of years to allow picking a year.
|
||||
class _YearPicker extends StatefulWidget {
|
||||
/// Creates a year picker.
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../button_bar.dart';
|
||||
@ -357,6 +357,11 @@ class _DatePickerDialogState extends State<_DatePickerDialog> {
|
||||
return null;
|
||||
}
|
||||
|
||||
static final Map<LogicalKeySet, Intent> _formShortcutMap = <LogicalKeySet, Intent>{
|
||||
// Pressing enter on the field will move focus to the next field or control.
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const NextFocusIntent(),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
@ -419,24 +424,27 @@ class _DatePickerDialogState extends State<_DatePickerDialog> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
height: orientation == Orientation.portrait ? _inputFormPortraitHeight : _inputFormLandscapeHeight,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const Spacer(),
|
||||
InputDatePickerFormField(
|
||||
initialDate: _selectedDate,
|
||||
firstDate: widget.firstDate,
|
||||
lastDate: widget.lastDate,
|
||||
onDateSubmitted: _handleDateChanged,
|
||||
onDateSaved: _handleDateChanged,
|
||||
selectableDayPredicate: widget.selectableDayPredicate,
|
||||
errorFormatText: widget.errorFormatText,
|
||||
errorInvalidText: widget.errorInvalidText,
|
||||
fieldHintText: widget.fieldHintText,
|
||||
fieldLabelText: widget.fieldLabelText,
|
||||
autofocus: true,
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
child: Shortcuts(
|
||||
shortcuts: _formShortcutMap,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const Spacer(),
|
||||
InputDatePickerFormField(
|
||||
initialDate: _selectedDate,
|
||||
firstDate: widget.firstDate,
|
||||
lastDate: widget.lastDate,
|
||||
onDateSubmitted: _handleDateChanged,
|
||||
onDateSaved: _handleDateChanged,
|
||||
selectableDayPredicate: widget.selectableDayPredicate,
|
||||
errorFormatText: widget.errorFormatText,
|
||||
errorInvalidText: widget.errorInvalidText,
|
||||
fieldHintText: widget.fieldHintText,
|
||||
fieldLabelText: widget.fieldLabelText,
|
||||
autofocus: true,
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -26,12 +26,20 @@ DateTimeRange datesOnly(DateTimeRange range) {
|
||||
}
|
||||
|
||||
/// Returns true if the two [DateTime] objects have the same day, month, and
|
||||
/// year.
|
||||
/// year, or are both null.
|
||||
bool isSameDay(DateTime dateA, DateTime dateB) {
|
||||
return
|
||||
dateA.year == dateB.year &&
|
||||
dateA.month == dateB.month &&
|
||||
dateA.day == dateB.day;
|
||||
dateA?.year == dateB?.year &&
|
||||
dateA?.month == dateB?.month &&
|
||||
dateA?.day == dateB?.day;
|
||||
}
|
||||
|
||||
/// Returns true if the two [DateTime] objects have the same month, and
|
||||
/// year, or are both null.
|
||||
bool isSameMonth(DateTime dateA, DateTime dateB) {
|
||||
return
|
||||
dateA?.year == dateB?.year &&
|
||||
dateA?.month == dateB?.month;
|
||||
}
|
||||
|
||||
/// Determines the number of months between two [DateTime] objects.
|
||||
|
||||
@ -83,7 +83,11 @@ void main() {
|
||||
SystemChannels.platform.setMockMethodCallHandler(null);
|
||||
});
|
||||
|
||||
Future<void> prepareDatePicker(WidgetTester tester, Future<void> callback(Future<DateTime> date)) async {
|
||||
Future<void> prepareDatePicker(
|
||||
WidgetTester tester,
|
||||
Future<void> callback(Future<DateTime> date),
|
||||
{ TextDirection textDirection = TextDirection.ltr }
|
||||
) async {
|
||||
BuildContext buttonContext;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Material(
|
||||
@ -119,6 +123,12 @@ void main() {
|
||||
fieldHintText: fieldHintText,
|
||||
fieldLabelText: fieldLabelText,
|
||||
helpText: helpText,
|
||||
builder: (BuildContext context, Widget child) {
|
||||
return Directionality(
|
||||
textDirection: textDirection,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
@ -845,123 +855,153 @@ void main() {
|
||||
expect(tester.getSemantics(find.text('1')), matchesSemantics(
|
||||
label: '1, Friday, January 1, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('2')), matchesSemantics(
|
||||
label: '2, Saturday, January 2, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('3')), matchesSemantics(
|
||||
label: '3, Sunday, January 3, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('4')), matchesSemantics(
|
||||
label: '4, Monday, January 4, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('5')), matchesSemantics(
|
||||
label: '5, Tuesday, January 5, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('6')), matchesSemantics(
|
||||
label: '6, Wednesday, January 6, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('7')), matchesSemantics(
|
||||
label: '7, Thursday, January 7, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('8')), matchesSemantics(
|
||||
label: '8, Friday, January 8, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('9')), matchesSemantics(
|
||||
label: '9, Saturday, January 9, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('10')), matchesSemantics(
|
||||
label: '10, Sunday, January 10, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('11')), matchesSemantics(
|
||||
label: '11, Monday, January 11, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('12')), matchesSemantics(
|
||||
label: '12, Tuesday, January 12, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('13')), matchesSemantics(
|
||||
label: '13, Wednesday, January 13, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('14')), matchesSemantics(
|
||||
label: '14, Thursday, January 14, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('15')), matchesSemantics(
|
||||
label: '15, Friday, January 15, 2016',
|
||||
hasTapAction: true,
|
||||
isSelected: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('16')), matchesSemantics(
|
||||
label: '16, Saturday, January 16, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('17')), matchesSemantics(
|
||||
label: '17, Sunday, January 17, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('18')), matchesSemantics(
|
||||
label: '18, Monday, January 18, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('19')), matchesSemantics(
|
||||
label: '19, Tuesday, January 19, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('20')), matchesSemantics(
|
||||
label: '20, Wednesday, January 20, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('21')), matchesSemantics(
|
||||
label: '21, Thursday, January 21, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('22')), matchesSemantics(
|
||||
label: '22, Friday, January 22, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('23')), matchesSemantics(
|
||||
label: '23, Saturday, January 23, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('24')), matchesSemantics(
|
||||
label: '24, Sunday, January 24, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('25')), matchesSemantics(
|
||||
label: '25, Monday, January 25, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('26')), matchesSemantics(
|
||||
label: '26, Tuesday, January 26, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('27')), matchesSemantics(
|
||||
label: '27, Wednesday, January 27, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('28')), matchesSemantics(
|
||||
label: '28, Thursday, January 28, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('29')), matchesSemantics(
|
||||
label: '29, Friday, January 29, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
expect(tester.getSemantics(find.text('30')), matchesSemantics(
|
||||
label: '30, Saturday, January 30, 2016',
|
||||
hasTapAction: true,
|
||||
isFocusable: true,
|
||||
));
|
||||
|
||||
// Ok/Cancel buttons
|
||||
@ -1113,6 +1153,7 @@ void main() {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
// Should be in the input mode
|
||||
@ -1159,6 +1200,128 @@ void main() {
|
||||
expect(find.text('March 2016'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Can navigate date grid with arrow keys', (WidgetTester tester) async {
|
||||
await prepareDatePicker(tester, (Future<DateTime> date) async {
|
||||
// Navigate to the grid
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
|
||||
// Navigate from Jan 15 to Jan 18 with arrow keys
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Activate it
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate out of the grid and to the OK button
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
|
||||
// Activate OK
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have selected Jan 18
|
||||
expect(await date, DateTime(2016, DateTime.january, 18));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Navigating with arrow keys scrolls months', (WidgetTester tester) async {
|
||||
await prepareDatePicker(tester, (Future<DateTime> date) async {
|
||||
// Navigate to the grid
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate from Jan 15 to Dec 31 with arrow keys
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have scrolled to Dec 2015
|
||||
expect(find.text('December 2015'), findsOneWidget);
|
||||
|
||||
// Navigate from Dec 31 to Nov 26 with arrow keys
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have scrolled to Nov 2015
|
||||
expect(find.text('November 2015'), findsOneWidget);
|
||||
|
||||
// Activate it
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate out of the grid and to the OK button
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Activate OK
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have selected Jan 18
|
||||
expect(await date, DateTime(2015, DateTime.november, 26));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('RTL text direction reverses the horizontal arrow key navigation', (WidgetTester tester) async {
|
||||
await prepareDatePicker(tester, (Future<DateTime> date) async {
|
||||
// Navigate to the grid
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate from Jan 15 to 19 with arrow keys
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Activate it
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate out of the grid and to the OK button
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Activate OK
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have selected Jan 18
|
||||
expect(await date, DateTime(2016, DateTime.january, 19));
|
||||
},
|
||||
textDirection: TextDirection.rtl);
|
||||
});
|
||||
});
|
||||
|
||||
group('Screen configurations', () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user