Unverified Commit 5fa937ed authored by Darren Austin's avatar Darren Austin Committed by GitHub

Keyboard navigation fo the Material Date Range Picker (#60497)

parent 6f4c4b3c
...@@ -470,7 +470,7 @@ class _MonthPicker extends StatefulWidget { ...@@ -470,7 +470,7 @@ class _MonthPicker extends StatefulWidget {
final SelectableDayPredicate selectableDayPredicate; final SelectableDayPredicate selectableDayPredicate;
@override @override
State<StatefulWidget> createState() => _MonthPickerState(); _MonthPickerState createState() => _MonthPickerState();
} }
class _MonthPickerState extends State<_MonthPicker> { class _MonthPickerState extends State<_MonthPicker> {
...@@ -754,16 +754,13 @@ class _MonthPickerState extends State<_MonthPicker> { ...@@ -754,16 +754,13 @@ class _MonthPickerState extends State<_MonthPicker> {
onFocusChange: _handleGridFocusChange, onFocusChange: _handleGridFocusChange,
child: _FocusedDate( child: _FocusedDate(
date: _dayGridFocus.hasFocus ? _focusedDay : null, date: _dayGridFocus.hasFocus ? _focusedDay : null,
child: Container( child: PageView.builder(
color: _dayGridFocus.hasFocus ? Theme.of(context).focusColor : null, key: _pageViewKey,
child: PageView.builder( controller: _pageController,
key: _pageViewKey, itemBuilder: _buildItems,
controller: _pageController, itemCount: utils.monthDelta(widget.firstDate, widget.lastDate) + 1,
itemBuilder: _buildItems, scrollDirection: Axis.horizontal,
itemCount: utils.monthDelta(widget.firstDate, widget.lastDate) + 1, onPageChanged: _handleMonthPageChanged,
scrollDirection: Axis.horizontal,
onPageChanged: _handleMonthPageChanged,
),
), ),
), ),
), ),
......
...@@ -13,12 +13,15 @@ import 'package:flutter/widgets.dart'; ...@@ -13,12 +13,15 @@ import 'package:flutter/widgets.dart';
import '../color_scheme.dart'; import '../color_scheme.dart';
import '../divider.dart'; import '../divider.dart';
import '../ink_well.dart';
import '../material_localizations.dart'; import '../material_localizations.dart';
import '../text_theme.dart'; import '../text_theme.dart';
import '../theme.dart'; import '../theme.dart';
import 'date_utils.dart' as utils; import 'date_utils.dart' as utils;
const Duration _monthScrollDuration = Duration(milliseconds: 200);
const double _monthItemHeaderHeight = 58.0; const double _monthItemHeaderHeight = 58.0;
const double _monthItemFooterHeight = 12.0; const double _monthItemFooterHeight = 12.0;
const double _monthItemRowHeight = 42.0; const double _monthItemRowHeight = 42.0;
...@@ -87,6 +90,7 @@ class CalendarDateRangePicker extends StatefulWidget { ...@@ -87,6 +90,7 @@ class CalendarDateRangePicker extends StatefulWidget {
} }
class _CalendarDateRangePickerState extends State<CalendarDateRangePicker> { class _CalendarDateRangePickerState extends State<CalendarDateRangePicker> {
final GlobalKey _scrollViewKey = GlobalKey();
DateTime _startDate; DateTime _startDate;
DateTime _endDate; DateTime _endDate;
int _initialMonthIndex = 0; int _initialMonthIndex = 0;
...@@ -195,26 +199,34 @@ class _CalendarDateRangePickerState extends State<CalendarDateRangePicker> { ...@@ -195,26 +199,34 @@ class _CalendarDateRangePickerState extends State<CalendarDateRangePicker> {
_DayHeaders(), _DayHeaders(),
if (_showWeekBottomDivider) const Divider(height: 0), if (_showWeekBottomDivider) const Divider(height: 0),
Expanded( Expanded(
// In order to prevent performance issues when displaying the child: _CalendarKeyboardNavigator(
// correct initial month, 2 `SliverList`s are used to split the firstDate: widget.firstDate,
// months. The first item in the second SliverList is the initial lastDate: widget.lastDate,
// month to be displayed. initialFocusedDay: _startDate ?? widget.initialStartDate ?? widget.currentDate,
child: CustomScrollView( // In order to prevent performance issues when displaying the
controller: _controller, // correct initial month, 2 `SliverList`s are used to split the
center: sliverAfterKey, // months. The first item in the second SliverList is the initial
slivers: <Widget>[ // month to be displayed.
SliverList( child: CustomScrollView(
delegate: SliverChildBuilderDelegate((BuildContext context, int index) => _buildMonthItem(context, index, true), key: _scrollViewKey,
childCount: _initialMonthIndex, controller: _controller,
center: sliverAfterKey,
slivers: <Widget>[
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => _buildMonthItem(context, index, true),
childCount: _initialMonthIndex,
),
), ),
), SliverList(
SliverList( key: sliverAfterKey,
key: sliverAfterKey, delegate: SliverChildBuilderDelegate(
delegate: SliverChildBuilderDelegate((BuildContext context, int index) => _buildMonthItem(context, index, false), (BuildContext context, int index) => _buildMonthItem(context, index, false),
childCount: _numberOfMonths - _initialMonthIndex, childCount: _numberOfMonths - _initialMonthIndex,
),
), ),
), ],
], ),
), ),
), ),
], ],
...@@ -222,6 +234,165 @@ class _CalendarDateRangePickerState extends State<CalendarDateRangePicker> { ...@@ -222,6 +234,165 @@ class _CalendarDateRangePickerState extends State<CalendarDateRangePicker> {
} }
} }
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> {
Map<LogicalKeySet, Intent> _shortcutMap;
Map<Type, Action<Intent>> _actionMap;
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 = utils.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,
),
);
}
}
/// 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,
this.scrollDirection,
}) : super(key: key, child: child);
final DateTime date;
final TraversalDirection scrollDirection;
@override
bool updateShouldNotify(_FocusedDate oldWidget) {
return !utils.isSameDay(date, oldWidget.date) || scrollDirection != oldWidget.scrollDirection;
}
static _FocusedDate of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_FocusedDate>();
}
}
class _DayHeaders extends StatelessWidget { class _DayHeaders extends StatelessWidget {
/// Builds widgets showing abbreviated days of week. The first widget in the /// 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. /// returned list corresponds to the first day of week for the current locale.
...@@ -401,7 +572,7 @@ class _MonthSliverGridLayout extends SliverGridLayout { ...@@ -401,7 +572,7 @@ class _MonthSliverGridLayout extends SliverGridLayout {
/// ///
/// The days are arranged in a rectangular grid with one column for each day of /// The days are arranged in a rectangular grid with one column for each day of
/// the week. /// the week.
class _MonthItem extends StatelessWidget { class _MonthItem extends StatefulWidget {
/// Creates a month item. /// Creates a month item.
_MonthItem({ _MonthItem({
Key key, Key key,
...@@ -471,9 +642,67 @@ class _MonthItem extends StatelessWidget { ...@@ -471,9 +642,67 @@ class _MonthItem extends StatelessWidget {
/// the different behaviors. /// the different behaviors.
final DragStartBehavior dragStartBehavior; final DragStartBehavior dragStartBehavior;
@override
_MonthItemState createState() => _MonthItemState();
}
class _MonthItemState extends State<_MonthItem> {
/// 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)?.date;
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();
}
Color _highlightColor(BuildContext context) { Color _highlightColor(BuildContext context) {
final ColorScheme colors = Theme.of(context).colorScheme; return Theme.of(context).colorScheme.primary.withOpacity(0.12);
return Color.alphaBlend(colors.primary.withOpacity(0.12), colors.background); }
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) { Widget _buildDayItem(BuildContext context, DateTime dayToBuild, int firstDayOffset, int daysInMonth) {
...@@ -485,17 +714,17 @@ class _MonthItem extends StatelessWidget { ...@@ -485,17 +714,17 @@ class _MonthItem extends StatelessWidget {
final Color highlightColor = _highlightColor(context); final Color highlightColor = _highlightColor(context);
final int day = dayToBuild.day; final int day = dayToBuild.day;
final bool isDisabled = dayToBuild.isAfter(lastDate) || dayToBuild.isBefore(firstDate); final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || dayToBuild.isBefore(widget.firstDate);
BoxDecoration decoration; BoxDecoration decoration;
TextStyle itemStyle = textTheme.bodyText2; TextStyle itemStyle = textTheme.bodyText2;
final bool isRangeSelected = selectedDateStart != null && selectedDateEnd != null; final bool isRangeSelected = widget.selectedDateStart != null && widget.selectedDateEnd != null;
final bool isSelectedDayStart = selectedDateStart != null && dayToBuild.isAtSameMomentAs(selectedDateStart); final bool isSelectedDayStart = widget.selectedDateStart != null && dayToBuild.isAtSameMomentAs(widget.selectedDateStart);
final bool isSelectedDayEnd = selectedDateEnd != null && dayToBuild.isAtSameMomentAs(selectedDateEnd); final bool isSelectedDayEnd = widget.selectedDateEnd != null && dayToBuild.isAtSameMomentAs(widget.selectedDateEnd);
final bool isInRange = isRangeSelected && final bool isInRange = isRangeSelected &&
dayToBuild.isAfter(selectedDateStart) && dayToBuild.isAfter(widget.selectedDateStart) &&
dayToBuild.isBefore(selectedDateEnd); dayToBuild.isBefore(widget.selectedDateEnd);
_HighlightPainter highlightPainter; _HighlightPainter highlightPainter;
...@@ -508,7 +737,7 @@ class _MonthItem extends StatelessWidget { ...@@ -508,7 +737,7 @@ class _MonthItem extends StatelessWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
); );
if (isRangeSelected && selectedDateStart != selectedDateEnd) { if (isRangeSelected && widget.selectedDateStart != widget.selectedDateEnd) {
final _HighlightPainterStyle style = isSelectedDayStart final _HighlightPainterStyle style = isSelectedDayStart
? _HighlightPainterStyle.highlightTrailing ? _HighlightPainterStyle.highlightTrailing
: _HighlightPainterStyle.highlightLeading; : _HighlightPainterStyle.highlightLeading;
...@@ -527,7 +756,7 @@ class _MonthItem extends StatelessWidget { ...@@ -527,7 +756,7 @@ class _MonthItem extends StatelessWidget {
); );
} else if (isDisabled) { } else if (isDisabled) {
itemStyle = textTheme.bodyText2?.apply(color: colorScheme.onSurface.withOpacity(0.38)); itemStyle = textTheme.bodyText2?.apply(color: colorScheme.onSurface.withOpacity(0.38));
} else if (utils.isSameDay(currentDate, dayToBuild)) { } else if (utils.isSameDay(widget.currentDate, dayToBuild)) {
// The current day gets a different text color and a circle stroke // The current day gets a different text color and a circle stroke
// border. // border.
itemStyle = textTheme.bodyText2?.apply(color: colorScheme.primary); itemStyle = textTheme.bodyText2?.apply(color: colorScheme.primary);
...@@ -543,12 +772,11 @@ class _MonthItem extends StatelessWidget { ...@@ -543,12 +772,11 @@ class _MonthItem extends StatelessWidget {
// day of month before the rest of the date, as they are looking // 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 // for the day of month. To do that we prepend day of month to the
// formatted full date. // formatted full date.
final String fullDate = localizations.formatFullDate(dayToBuild); String semanticLabel = '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}';
String semanticLabel = fullDate;
if (isSelectedDayStart) { if (isSelectedDayStart) {
semanticLabel = localizations.dateRangeStartDateSemanticLabel(fullDate); semanticLabel = localizations.dateRangeStartDateSemanticLabel(semanticLabel);
} else if (isSelectedDayEnd) { } else if (isSelectedDayEnd) {
semanticLabel = localizations.dateRangeEndDateSemanticLabel(fullDate); semanticLabel = localizations.dateRangeEndDateSemanticLabel(semanticLabel);
} }
Widget dayWidget = Container( Widget dayWidget = Container(
...@@ -572,11 +800,13 @@ class _MonthItem extends StatelessWidget { ...@@ -572,11 +800,13 @@ class _MonthItem extends StatelessWidget {
} }
if (!isDisabled) { if (!isDisabled) {
dayWidget = GestureDetector( dayWidget = InkResponse(
behavior: HitTestBehavior.opaque, focusNode: _dayFocusNodes[day - 1],
onTap: () { onChanged(dayToBuild); }, onTap: () => widget.onChanged(dayToBuild),
radius: _monthItemRowHeight / 2 + 4,
splashColor: colorScheme.primary.withOpacity(0.38),
onFocusChange: _dayFocusChanged,
child: dayWidget, child: dayWidget,
dragStartBehavior: dragStartBehavior,
); );
} }
...@@ -592,8 +822,8 @@ class _MonthItem extends StatelessWidget { ...@@ -592,8 +822,8 @@ class _MonthItem extends StatelessWidget {
final ThemeData themeData = Theme.of(context); final ThemeData themeData = Theme.of(context);
final TextTheme textTheme = themeData.textTheme; final TextTheme textTheme = themeData.textTheme;
final MaterialLocalizations localizations = MaterialLocalizations.of(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final int year = displayedMonth.year; final int year = widget.displayedMonth.year;
final int month = displayedMonth.month; final int month = widget.displayedMonth.month;
final int daysInMonth = utils.getDaysInMonth(year, month); final int daysInMonth = utils.getDaysInMonth(year, month);
final int dayOffset = utils.firstDayOffset(year, month, localizations); final int dayOffset = utils.firstDayOffset(year, month, localizations);
final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil(); final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil();
...@@ -637,10 +867,10 @@ class _MonthItem extends StatelessWidget { ...@@ -637,10 +867,10 @@ class _MonthItem extends StatelessWidget {
// on/before the end date. // on/before the end date.
final bool isLeadingInRange = final bool isLeadingInRange =
!(dayOffset > 0 && i == 0) && !(dayOffset > 0 && i == 0) &&
selectedDateStart != null && widget.selectedDateStart != null &&
selectedDateEnd != null && widget.selectedDateEnd != null &&
dateAfterLeadingPadding.isAfter(selectedDateStart) && dateAfterLeadingPadding.isAfter(widget.selectedDateStart) &&
!dateAfterLeadingPadding.isAfter(selectedDateEnd); !dateAfterLeadingPadding.isAfter(widget.selectedDateEnd);
weekList.insert(0, _buildEdgeContainer(context, isLeadingInRange)); weekList.insert(0, _buildEdgeContainer(context, isLeadingInRange));
// Only add a trailing edge container if it is for a full week and not a // Only add a trailing edge container if it is for a full week and not a
...@@ -651,10 +881,10 @@ class _MonthItem extends StatelessWidget { ...@@ -651,10 +881,10 @@ class _MonthItem extends StatelessWidget {
// Only color the edge container if it is on/after the start date and // Only color the edge container if it is on/after the start date and
// before the end date. // before the end date.
final bool isTrailingInRange = final bool isTrailingInRange =
selectedDateStart != null && widget.selectedDateStart != null &&
selectedDateEnd != null && widget.selectedDateEnd != null &&
!dateBeforeTrailingPadding.isBefore(selectedDateStart) && !dateBeforeTrailingPadding.isBefore(widget.selectedDateStart) &&
dateBeforeTrailingPadding.isBefore(selectedDateEnd); dateBeforeTrailingPadding.isBefore(widget.selectedDateEnd);
weekList.add(_buildEdgeContainer(context, isTrailingInRange)); weekList.add(_buildEdgeContainer(context, isTrailingInRange));
} }
...@@ -673,7 +903,7 @@ class _MonthItem extends StatelessWidget { ...@@ -673,7 +903,7 @@ class _MonthItem extends StatelessWidget {
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: ExcludeSemantics( child: ExcludeSemantics(
child: Text( child: Text(
localizations.formatMonthYear(displayedMonth), localizations.formatMonthYear(widget.displayedMonth),
style: textTheme.bodyText2.apply(color: themeData.colorScheme.onSurface), style: textTheme.bodyText2.apply(color: themeData.colorScheme.onSurface),
), ),
), ),
...@@ -740,11 +970,8 @@ class _HighlightPainter extends CustomPainter { ...@@ -740,11 +970,8 @@ class _HighlightPainter extends CustomPainter {
..color = color ..color = color
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
// This ensures no gaps in the highlight track due to floating point final Rect rectLeft = Rect.fromLTWH(0, 0, size.width / 2, size.height);
// division of the available screen width. final Rect rectRight = Rect.fromLTWH(size.width / 2, 0, size.width / 2, size.height);
final double width = size.width + 1;
final Rect rectLeft = Rect.fromLTWH(0, 0, width / 2, size.height);
final Rect rectRight = Rect.fromLTWH(size.width / 2, 0, width / 2, size.height);
switch (style) { switch (style) {
case _HighlightPainterStyle.highlightTrailing: case _HighlightPainterStyle.highlightTrailing:
...@@ -761,7 +988,7 @@ class _HighlightPainter extends CustomPainter { ...@@ -761,7 +988,7 @@ class _HighlightPainter extends CustomPainter {
break; break;
case _HighlightPainterStyle.highlightAll: case _HighlightPainterStyle.highlightAll:
canvas.drawRect( canvas.drawRect(
Rect.fromLTWH(0, 0, width, size.height), Rect.fromLTWH(0, 0, size.width, size.height),
paint, paint,
); );
break; break;
......
...@@ -88,4 +88,7 @@ class DateTimeRange { ...@@ -88,4 +88,7 @@ class DateTimeRange {
@override @override
int get hashCode => hashValues(start, end); int get hashCode => hashValues(start, end);
@override
String toString() => '$start - $end';
} }
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
// @dart = 2.8 // @dart = 2.8
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'feedback_tester.dart'; import 'feedback_tester.dart';
...@@ -49,7 +50,11 @@ void main() { ...@@ -49,7 +50,11 @@ void main() {
saveText = null; saveText = null;
}); });
Future<void> preparePicker(WidgetTester tester, Future<void> callback(Future<DateTimeRange> date)) async { Future<void> preparePicker(
WidgetTester tester,
Future<void> callback(Future<DateTimeRange> date),
{ TextDirection textDirection = TextDirection.ltr }
) async {
BuildContext buttonContext; BuildContext buttonContext;
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: Material( home: Material(
...@@ -86,6 +91,12 @@ void main() { ...@@ -86,6 +91,12 @@ void main() {
fieldEndLabelText: fieldEndLabelText, fieldEndLabelText: fieldEndLabelText,
helpText: helpText, helpText: helpText,
saveText: saveText, saveText: saveText,
builder: (BuildContext context, Widget child) {
return Directionality(
textDirection: textDirection,
child: child,
);
},
); );
await tester.pumpAndSettle(const Duration(seconds: 1)); await tester.pumpAndSettle(const Duration(seconds: 1));
...@@ -237,6 +248,186 @@ void main() { ...@@ -237,6 +248,186 @@ void main() {
}); });
}); });
group('Keyboard navigation', () {
testWidgets('Can toggle to calendar entry mode', (WidgetTester tester) async {
await preparePicker(tester, (Future<DateTimeRange> range) async {
expect(find.byType(TextField), findsNothing);
// Navigate to the entry toggle button and activate it
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
// Should be in the input mode
expect(find.byType(TextField), findsNWidgets(2));
});
});
testWidgets('Can navigate date grid with arrow keys', (WidgetTester tester) async {
await preparePicker(tester, (Future<DateTimeRange> range) 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 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 to select the beginning of the range
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
// Navigate to Jan 29
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
// Activate it to select the end of the range
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 - Jan 29
expect(await range, DateTimeRange(
start: DateTime(2016, DateTime.january, 18),
end: DateTime(2016, DateTime.january, 29),
));
});
});
testWidgets('Navigating with arrow keys scrolls as needed', (WidgetTester tester) async {
await preparePicker(tester, (Future<DateTimeRange> range) async {
// Jan and Feb headers should be showing, but no Mar
expect(find.text('January 2016'), findsOneWidget);
expect(find.text('February 2016'), findsOneWidget);
expect(find.text('Mar 2016'), findsNothing);
// 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.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
// Activate it to select the beginning of the range
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
// Navigate to Mar 17
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
// Jan should have scrolled off, Mar should be visible
expect(find.text('January 2016'), findsNothing);
expect(find.text('February 2016'), findsOneWidget);
expect(find.text('March 2016'), findsOneWidget);
// Activate it to select the end of the range
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 - Mar 17
expect(await range, DateTimeRange(
start: DateTime(2016, DateTime.january, 18),
end: DateTime(2016, DateTime.march, 17),
));
});
});
testWidgets('RTL text direction reverses the horizontal arrow key navigation', (WidgetTester tester) async {
await preparePicker(tester, (Future<DateTimeRange> range) 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 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 to Jan 21
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 19 - Mar 21
expect(await range, DateTimeRange(
start: DateTime(2016, DateTime.january, 19),
end: DateTime(2016, DateTime.january, 21),
));
},
textDirection: TextDirection.rtl);
});
});
group('Input mode', () { group('Input mode', () {
setUp(() { setUp(() {
firstDate = DateTime(2015, DateTime.january, 1); firstDate = DateTime(2015, DateTime.january, 1);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment