Commit 959db13c authored by xster's avatar xster Committed by GitHub

Prevent out of bound date picker selections (#7773)

- Out of bound days are disabled and untappable
- Out of bounds months can't be navigated to
parent 4c0fdc02
...@@ -170,12 +170,16 @@ class DayPicker extends StatelessWidget { ...@@ -170,12 +170,16 @@ class DayPicker extends StatelessWidget {
@required this.selectedDate, @required this.selectedDate,
@required this.currentDate, @required this.currentDate,
@required this.onChanged, @required this.onChanged,
@required this.firstDate,
@required this.lastDate,
@required this.displayedMonth @required this.displayedMonth
}) : super(key: key) { }) : super(key: key) {
assert(selectedDate != null); assert(selectedDate != null);
assert(currentDate != null); assert(currentDate != null);
assert(onChanged != null); assert(onChanged != null);
assert(displayedMonth != null); assert(displayedMonth != null);
assert(!firstDate.isAfter(lastDate));
assert(selectedDate.isAfter(firstDate) || selectedDate.isAtSameMomentAs(firstDate));
} }
/// The currently selected date. /// The currently selected date.
...@@ -189,6 +193,12 @@ class DayPicker extends StatelessWidget { ...@@ -189,6 +193,12 @@ class DayPicker extends StatelessWidget {
/// Called when the user picks a day. /// Called when the user picks a day.
final ValueChanged<DateTime> onChanged; 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. /// The month whose days are displayed by this picker.
final DateTime displayedMonth; final DateTime displayedMonth;
...@@ -219,6 +229,9 @@ class DayPicker extends StatelessWidget { ...@@ -219,6 +229,9 @@ class DayPicker extends StatelessWidget {
if (day < 1) { if (day < 1) {
labels.add(new Container()); labels.add(new Container());
} else { } else {
final DateTime dayToBuild = new DateTime(year, month, day);
final bool disabled = dayToBuild.isAfter(lastDate) || dayToBuild.isBefore(firstDate);
BoxDecoration decoration; BoxDecoration decoration;
TextStyle itemStyle = themeData.textTheme.body1; TextStyle itemStyle = themeData.textTheme.body1;
...@@ -229,24 +242,31 @@ class DayPicker extends StatelessWidget { ...@@ -229,24 +242,31 @@ class DayPicker extends StatelessWidget {
backgroundColor: themeData.accentColor, backgroundColor: themeData.accentColor,
shape: BoxShape.circle shape: BoxShape.circle
); );
} else if (disabled) {
itemStyle = themeData.textTheme.body1.copyWith(color: themeData.disabledColor);
} else if (currentDate.year == year && currentDate.month == month && currentDate.day == day) { } else if (currentDate.year == year && currentDate.month == month && currentDate.day == day) {
// The current day gets a different text color. // The current day gets a different text color.
itemStyle = themeData.textTheme.body2.copyWith(color: themeData.accentColor); itemStyle = themeData.textTheme.body2.copyWith(color: themeData.accentColor);
} }
labels.add(new GestureDetector( Widget dayWidget = new Container(
behavior: HitTestBehavior.opaque, decoration: decoration,
onTap: () { child: new Center(
DateTime result = new DateTime(year, month, day); child: new Text(day.toString(), style: itemStyle)
onChanged(result);
},
child: new Container(
decoration: decoration,
child: new Center(
child: new Text(day.toString(), style: itemStyle)
)
) )
)); );
if (!disabled) {
dayWidget = new GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
onChanged(dayToBuild);
},
child: dayWidget
);
}
labels.add(dayWidget);
} }
} }
...@@ -299,7 +319,7 @@ class MonthPicker extends StatefulWidget { ...@@ -299,7 +319,7 @@ class MonthPicker extends StatefulWidget {
}) : super(key: key) { }) : super(key: key) {
assert(selectedDate != null); assert(selectedDate != null);
assert(onChanged != null); assert(onChanged != null);
assert(lastDate.isAfter(firstDate)); assert(!firstDate.isAfter(lastDate));
assert(selectedDate.isAfter(firstDate) || selectedDate.isAtSameMomentAs(firstDate)); assert(selectedDate.isAfter(firstDate) || selectedDate.isAtSameMomentAs(firstDate));
} }
...@@ -325,23 +345,29 @@ class _MonthPickerState extends State<MonthPicker> { ...@@ -325,23 +345,29 @@ class _MonthPickerState extends State<MonthPicker> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initially display the pre-selected date.
_currentDisplayedMonthDate = new DateTime(config.selectedDate.year, config.selectedDate.month);
_updateCurrentDate(); _updateCurrentDate();
} }
@override @override
void didUpdateConfig(MonthPicker oldConfig) { void didUpdateConfig(MonthPicker oldConfig) {
if (config.selectedDate != oldConfig.selectedDate) if (config.selectedDate != oldConfig.selectedDate)
_dayPickerListKey = new GlobalKey<ScrollableState>(); _dayPickerListKey = new GlobalKey<PageableState<PageableLazyList>>();
_currentDisplayedMonthDate =
new DateTime(config.selectedDate.year, config.selectedDate.month);
} }
DateTime _currentDate; DateTime _todayDate;
DateTime _currentDisplayedMonthDate;
Timer _timer; Timer _timer;
GlobalKey<ScrollableState> _dayPickerListKey = new GlobalKey<ScrollableState>(); GlobalKey<PageableState<PageableLazyList>> _dayPickerListKey =
new GlobalKey<PageableState<PageableLazyList>>();
void _updateCurrentDate() { void _updateCurrentDate() {
_currentDate = new DateTime.now(); _todayDate = new DateTime.now();
DateTime tomorrow = new DateTime(_currentDate.year, _currentDate.month, _currentDate.day + 1); DateTime tomorrow = new DateTime(_todayDate.year, _todayDate.month, _todayDate.day + 1);
Duration timeUntilTomorrow = tomorrow.difference(_currentDate); Duration timeUntilTomorrow = tomorrow.difference(_todayDate);
timeUntilTomorrow += const Duration(seconds: 1); // so we don't miss it by rounding timeUntilTomorrow += const Duration(seconds: 1); // so we don't miss it by rounding
if (_timer != null) if (_timer != null)
_timer.cancel(); _timer.cancel();
...@@ -356,30 +382,57 @@ class _MonthPickerState extends State<MonthPicker> { ...@@ -356,30 +382,57 @@ class _MonthPickerState extends State<MonthPicker> {
return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month; return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month;
} }
/// Add months to a month truncated date.
DateTime _addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) {
return new DateTime(monthDate.year + monthsToAdd ~/ 12, monthDate.month + monthsToAdd % 12);
}
List<Widget> _buildItems(BuildContext context, int start, int count) { List<Widget> _buildItems(BuildContext context, int start, int count) {
final List<Widget> result = new List<Widget>(); final List<Widget> result = new List<Widget>();
final DateTime startDate = new DateTime(config.firstDate.year + start ~/ 12, config.firstDate.month + start % 12); final DateTime startMonthDate = _addMonthsToMonthDate(config.firstDate, start);
for (int i = 0; i < count; ++i) { for (int i = 0; i < count; ++i) {
DateTime displayedMonth = new DateTime(startDate.year + i ~/ 12, startDate.month + i % 12); DateTime monthToBuild = _addMonthsToMonthDate(startMonthDate, i);
result.add(new DayPicker( result.add(new DayPicker(
key: new ValueKey<DateTime>(displayedMonth), key: new ValueKey<DateTime>(monthToBuild),
selectedDate: config.selectedDate, selectedDate: config.selectedDate,
currentDate: _currentDate, currentDate: _todayDate,
onChanged: config.onChanged, onChanged: config.onChanged,
displayedMonth: displayedMonth firstDate: config.firstDate,
lastDate: config.lastDate,
displayedMonth: monthToBuild
)); ));
} }
return result; return result;
} }
void _handleNextMonth() { void _handleNextMonth() {
ScrollableState state = _dayPickerListKey.currentState; if (!_isDisplayingLastMonth) {
state?.scrollTo(state.scrollOffset.round() + 1.0, duration: _kMonthScrollDuration); _dayPickerListKey.currentState?.fling(1.0);
}
} }
void _handlePreviousMonth() { void _handlePreviousMonth() {
ScrollableState state = _dayPickerListKey.currentState; if (!_isDisplayingFirstMonth) {
state?.scrollTo(state.scrollOffset.round() - 1.0, duration: _kMonthScrollDuration); _dayPickerListKey.currentState?.fling(-1.0);
}
}
/// True if the earliest allowable month is displayed.
bool get _isDisplayingFirstMonth {
return !_currentDisplayedMonthDate.isAfter(
new DateTime(config.firstDate.year, config.firstDate.month));
}
/// True if the latest allowable month is displayed.
bool get _isDisplayingLastMonth {
return !_currentDisplayedMonthDate.isBefore(
new DateTime(config.lastDate.year, config.lastDate.month));
}
void _handleMonthPageChanged(int monthPage) {
setState(() {
_currentDisplayedMonthDate = _addMonthsToMonthDate(config.firstDate, monthPage);
});
} }
@override @override
...@@ -394,7 +447,9 @@ class _MonthPickerState extends State<MonthPicker> { ...@@ -394,7 +447,9 @@ class _MonthPickerState extends State<MonthPicker> {
initialScrollOffset: _monthDelta(config.firstDate, config.selectedDate).toDouble(), initialScrollOffset: _monthDelta(config.firstDate, config.selectedDate).toDouble(),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: _monthDelta(config.firstDate, config.lastDate) + 1, itemCount: _monthDelta(config.firstDate, config.lastDate) + 1,
itemBuilder: _buildItems itemBuilder: _buildItems,
duration: _kMonthScrollDuration,
onPageChanged: _handleMonthPageChanged
), ),
new Positioned( new Positioned(
top: 0.0, top: 0.0,
...@@ -402,7 +457,7 @@ class _MonthPickerState extends State<MonthPicker> { ...@@ -402,7 +457,7 @@ class _MonthPickerState extends State<MonthPicker> {
child: new IconButton( child: new IconButton(
icon: new Icon(Icons.chevron_left), icon: new Icon(Icons.chevron_left),
tooltip: 'Previous month', tooltip: 'Previous month',
onPressed: _handlePreviousMonth onPressed: _isDisplayingFirstMonth ? null : _handlePreviousMonth
) )
), ),
new Positioned( new Positioned(
...@@ -411,7 +466,7 @@ class _MonthPickerState extends State<MonthPicker> { ...@@ -411,7 +466,7 @@ class _MonthPickerState extends State<MonthPicker> {
child: new IconButton( child: new IconButton(
icon: new Icon(Icons.chevron_right), icon: new Icon(Icons.chevron_right),
tooltip: 'Next month', tooltip: 'Next month',
onPressed: _handleNextMonth onPressed: _isDisplayingLastMonth ? null : _handleNextMonth
) )
) )
] ]
...@@ -454,7 +509,7 @@ class YearPicker extends StatefulWidget { ...@@ -454,7 +509,7 @@ class YearPicker extends StatefulWidget {
}) : super(key: key) { }) : super(key: key) {
assert(selectedDate != null); assert(selectedDate != null);
assert(onChanged != null); assert(onChanged != null);
assert(lastDate.isAfter(firstDate)); assert(!firstDate.isAfter(lastDate));
} }
/// The currently selected date. /// The currently selected date.
......
...@@ -315,7 +315,7 @@ abstract class PageableState<T extends Pageable> extends ScrollableState<T> { ...@@ -315,7 +315,7 @@ abstract class PageableState<T extends Pageable> extends ScrollableState<T> {
Future<Null> _flingToAdjacentItem(double scrollVelocity) { Future<Null> _flingToAdjacentItem(double scrollVelocity) {
final double newScrollOffset = snapScrollOffset(scrollOffset + scrollVelocity.sign) final double newScrollOffset = snapScrollOffset(scrollOffset + scrollVelocity.sign)
.clamp(snapScrollOffset(scrollOffset - 0.5), snapScrollOffset(scrollOffset + 0.5)); .clamp(snapScrollOffset(scrollOffset - 0.50001), snapScrollOffset(scrollOffset + 0.5));
return scrollTo(newScrollOffset, duration: config.duration, curve: config.curve) return scrollTo(newScrollOffset, duration: config.duration, curve: config.curve)
.then<Null>(_notifyPageChanged); .then<Null>(_notifyPageChanged);
} }
......
...@@ -7,6 +7,16 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -7,6 +7,16 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
void main() { void main() {
DateTime firstDate;
DateTime lastDate;
DateTime initialDate;
setUp(() {
firstDate = new DateTime(2001, DateTime.JANUARY, 1);
lastDate = new DateTime(2031, DateTime.DECEMBER, 31);
initialDate = new DateTime(2016, DateTime.JANUARY, 15);
});
testWidgets('tap-select a day', (WidgetTester tester) async { testWidgets('tap-select a day', (WidgetTester tester) async {
Key _datePickerKey = new UniqueKey(); Key _datePickerKey = new UniqueKey();
DateTime _selectedDate = new DateTime(2016, DateTime.JULY, 26); DateTime _selectedDate = new DateTime(2016, DateTime.JULY, 26);
...@@ -131,9 +141,9 @@ void main() { ...@@ -131,9 +141,9 @@ void main() {
Future<DateTime> date = showDatePicker( Future<DateTime> date = showDatePicker(
context: buttonContext, context: buttonContext,
initialDate: new DateTime(2016, DateTime.JANUARY, 15), initialDate: initialDate,
firstDate: new DateTime(2001, DateTime.JANUARY, 1), firstDate: firstDate,
lastDate: new DateTime(2031, DateTime.DECEMBER, 31), lastDate: lastDate,
); );
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1)); await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
...@@ -196,4 +206,55 @@ void main() { ...@@ -196,4 +206,55 @@ void main() {
expect(await date, equals(new DateTime(2005, DateTime.JANUARY, 19))); expect(await date, equals(new DateTime(2005, DateTime.JANUARY, 19)));
}); });
}); });
testWidgets('Cannot select a day outside bounds', (WidgetTester tester) async {
initialDate = new DateTime(2017, DateTime.JANUARY, 15);
firstDate = initialDate;
lastDate = initialDate;
await preparePicker(tester, (Future<DateTime> date) async {
await tester.tap(find.text('10')); // Earlier than firstDate. Should be ignored.
await tester.tap(find.text('20')); // Later than lastDate. Should be ignored.
await tester.tap(find.text('OK'));
// We should still be on the inital date.
expect(await date, equals(initialDate));
});
});
testWidgets('Cannot select a month past last date', (WidgetTester tester) async {
initialDate = new DateTime(2017, DateTime.JANUARY, 15);
firstDate = initialDate;
lastDate = new DateTime(2017, DateTime.FEBRUARY, 20);
await preparePicker(tester, (Future<DateTime> date) async {
await tester.tap(find.byTooltip('Next month'));
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
// Shouldn't be possible to keep going into March.
await tester.tap(find.byTooltip('Next month'));
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
// We're still in February
await tester.tap(find.text('20'));
// Days outside bound for new month pages also disabled.
await tester.tap(find.text('25'));
await tester.tap(find.text('OK'));
expect(await date, equals(new DateTime(2017, DateTime.FEBRUARY, 20)));
});
});
testWidgets('Cannot select a month before first date', (WidgetTester tester) async {
initialDate = new DateTime(2017, DateTime.JANUARY, 15);
firstDate = new DateTime(2016, DateTime.DECEMBER, 10);
lastDate = initialDate;
await preparePicker(tester, (Future<DateTime> date) async {
await tester.tap(find.byTooltip('Previous month'));
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
// Shouldn't be possible to keep going into November.
await tester.tap(find.byTooltip('Previous month'));
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
// We're still in December
await tester.tap(find.text('10'));
// Days outside bound for new month pages also disabled.
await tester.tap(find.text('5'));
await tester.tap(find.text('OK'));
expect(await date, equals(new DateTime(2016, DateTime.DECEMBER, 10)));
});
});
} }
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