1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'package:flutter/foundation.dart'; |
6 | import 'package:flutter/material.dart'; |
7 | import 'package:flutter/semantics.dart'; |
8 | import 'package:flutter/services.dart'; |
9 | import 'package:flutter_test/flutter_test.dart'; |
10 | |
11 | import '../widgets/clipboard_utils.dart'; |
12 | |
13 | class TestMaterialLocalizations extends DefaultMaterialLocalizations { |
14 | @override |
15 | String formatCompactDate(DateTime date) { |
16 | return ' ${date.month}/ ${date.day}/ ${date.year}' ; |
17 | } |
18 | } |
19 | |
20 | class TestMaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { |
21 | @override |
22 | bool isSupported(Locale locale) => true; |
23 | |
24 | @override |
25 | Future<MaterialLocalizations> load(Locale locale) { |
26 | return SynchronousFuture<MaterialLocalizations>(TestMaterialLocalizations()); |
27 | } |
28 | |
29 | @override |
30 | bool shouldReload(TestMaterialLocalizationsDelegate old) => false; |
31 | } |
32 | |
33 | void main() { |
34 | TestWidgetsFlutterBinding.ensureInitialized(); |
35 | final MockClipboard mockClipboard = MockClipboard(); |
36 | |
37 | Widget inputDatePickerField({ |
38 | Key? key, |
39 | DateTime? initialDate, |
40 | DateTime? firstDate, |
41 | DateTime? lastDate, |
42 | ValueChanged<DateTime>? onDateSubmitted, |
43 | ValueChanged<DateTime>? onDateSaved, |
44 | SelectableDayPredicate? selectableDayPredicate, |
45 | String? errorFormatText, |
46 | String? errorInvalidText, |
47 | String? fieldHintText, |
48 | String? fieldLabelText, |
49 | bool autofocus = false, |
50 | Key? formKey, |
51 | ThemeData? theme, |
52 | Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates, |
53 | bool acceptEmptyDate = false, |
54 | FocusNode? focusNode, |
55 | }) { |
56 | return MaterialApp( |
57 | theme: theme ?? ThemeData.from(colorScheme: const ColorScheme.light()), |
58 | localizationsDelegates: localizationsDelegates, |
59 | home: Material( |
60 | child: Form( |
61 | key: formKey, |
62 | child: InputDatePickerFormField( |
63 | key: key, |
64 | initialDate: initialDate ?? DateTime(2016, DateTime.january, 15), |
65 | firstDate: firstDate ?? DateTime(2001), |
66 | lastDate: lastDate ?? DateTime(2031, DateTime.december, 31), |
67 | onDateSubmitted: onDateSubmitted, |
68 | onDateSaved: onDateSaved, |
69 | selectableDayPredicate: selectableDayPredicate, |
70 | errorFormatText: errorFormatText, |
71 | errorInvalidText: errorInvalidText, |
72 | fieldHintText: fieldHintText, |
73 | fieldLabelText: fieldLabelText, |
74 | autofocus: autofocus, |
75 | acceptEmptyDate: acceptEmptyDate, |
76 | focusNode: focusNode, |
77 | ), |
78 | ), |
79 | ), |
80 | ); |
81 | } |
82 | |
83 | TextField textField(WidgetTester tester) { |
84 | return tester.widget<TextField>(find.byType(TextField)); |
85 | } |
86 | |
87 | TextEditingController textFieldController(WidgetTester tester) { |
88 | return textField(tester).controller!; |
89 | } |
90 | |
91 | double textOpacity(WidgetTester tester, String textValue) { |
92 | final FadeTransition opacityWidget = tester.widget<FadeTransition>( |
93 | find.ancestor(of: find.text(textValue), matching: find.byType(FadeTransition)).first, |
94 | ); |
95 | return opacityWidget.opacity.value; |
96 | } |
97 | |
98 | group('InputDatePickerFormField' , () { |
99 | testWidgets('Initial date is the default' , (WidgetTester tester) async { |
100 | final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
101 | final DateTime initialDate = DateTime(2016, DateTime.february, 21); |
102 | DateTime? inputDate; |
103 | await tester.pumpWidget( |
104 | inputDatePickerField( |
105 | initialDate: initialDate, |
106 | onDateSaved: (DateTime date) => inputDate = date, |
107 | formKey: formKey, |
108 | ), |
109 | ); |
110 | expect(textFieldController(tester).value.text, equals('02/21/2016' )); |
111 | formKey.currentState!.save(); |
112 | expect(inputDate, equals(initialDate)); |
113 | }); |
114 | |
115 | testWidgets('Changing initial date is reflected in text value' , (WidgetTester tester) async { |
116 | final DateTime initialDate = DateTime(2016, DateTime.february, 21); |
117 | final DateTime updatedInitialDate = DateTime(2016, DateTime.february, 23); |
118 | await tester.pumpWidget(inputDatePickerField(initialDate: initialDate)); |
119 | expect(textFieldController(tester).value.text, equals('02/21/2016' )); |
120 | |
121 | await tester.pumpWidget(inputDatePickerField(initialDate: updatedInitialDate)); |
122 | await tester.pumpAndSettle(); |
123 | expect(textFieldController(tester).value.text, equals('02/23/2016' )); |
124 | }); |
125 | |
126 | testWidgets('Valid date entry' , (WidgetTester tester) async { |
127 | final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
128 | DateTime? inputDate; |
129 | await tester.pumpWidget( |
130 | inputDatePickerField(onDateSaved: (DateTime date) => inputDate = date, formKey: formKey), |
131 | ); |
132 | |
133 | textFieldController(tester).text = '02/21/2016' ; |
134 | formKey.currentState!.save(); |
135 | expect(inputDate, equals(DateTime(2016, DateTime.february, 21))); |
136 | }); |
137 | |
138 | testWidgets('Invalid text entry shows errorFormat text' , (WidgetTester tester) async { |
139 | final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
140 | DateTime? inputDate; |
141 | await tester.pumpWidget( |
142 | inputDatePickerField(onDateSaved: (DateTime date) => inputDate = date, formKey: formKey), |
143 | ); |
144 | // Default errorFormat text |
145 | expect(find.text('Invalid format.' ), findsNothing); |
146 | await tester.enterText(find.byType(TextField), 'foobar' ); |
147 | expect(formKey.currentState!.validate(), isFalse); |
148 | await tester.pumpAndSettle(); |
149 | expect(inputDate, isNull); |
150 | expect(find.text('Invalid format.' ), findsOneWidget); |
151 | |
152 | // Change to a custom errorFormat text |
153 | await tester.pumpWidget( |
154 | inputDatePickerField( |
155 | onDateSaved: (DateTime date) => inputDate = date, |
156 | errorFormatText: 'That is not a date.' , |
157 | formKey: formKey, |
158 | ), |
159 | ); |
160 | expect(formKey.currentState!.validate(), isFalse); |
161 | await tester.pumpAndSettle(); |
162 | expect(find.text('Invalid format.' ), findsNothing); |
163 | expect(find.text('That is not a date.' ), findsOneWidget); |
164 | }); |
165 | |
166 | testWidgets( |
167 | 'Valid text entry, but date outside first or last date shows bounds shows errorInvalid text' , |
168 | (WidgetTester tester) async { |
169 | final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
170 | DateTime? inputDate; |
171 | await tester.pumpWidget( |
172 | inputDatePickerField( |
173 | firstDate: DateTime(1966, DateTime.february, 21), |
174 | lastDate: DateTime(2040, DateTime.february, 23), |
175 | onDateSaved: (DateTime date) => inputDate = date, |
176 | formKey: formKey, |
177 | ), |
178 | ); |
179 | // Default errorInvalid text |
180 | expect(find.text('Out of range.' ), findsNothing); |
181 | // Before first date |
182 | await tester.enterText(find.byType(TextField), '02/21/1950' ); |
183 | expect(formKey.currentState!.validate(), isFalse); |
184 | await tester.pumpAndSettle(); |
185 | expect(inputDate, isNull); |
186 | expect(find.text('Out of range.' ), findsOneWidget); |
187 | // After last date |
188 | await tester.enterText(find.byType(TextField), '02/23/2050' ); |
189 | expect(formKey.currentState!.validate(), isFalse); |
190 | await tester.pumpAndSettle(); |
191 | expect(inputDate, isNull); |
192 | expect(find.text('Out of range.' ), findsOneWidget); |
193 | |
194 | await tester.pumpWidget( |
195 | inputDatePickerField( |
196 | onDateSaved: (DateTime date) => inputDate = date, |
197 | errorInvalidText: 'Not in given range.' , |
198 | formKey: formKey, |
199 | ), |
200 | ); |
201 | expect(formKey.currentState!.validate(), isFalse); |
202 | await tester.pumpAndSettle(); |
203 | expect(find.text('Out of range.' ), findsNothing); |
204 | expect(find.text('Not in given range.' ), findsOneWidget); |
205 | }, |
206 | ); |
207 | |
208 | testWidgets( |
209 | 'selectableDatePredicate will be used to show errorInvalid if date is not selectable' , |
210 | (WidgetTester tester) async { |
211 | final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
212 | DateTime? inputDate; |
213 | await tester.pumpWidget( |
214 | inputDatePickerField( |
215 | initialDate: DateTime(2016, DateTime.january, 16), |
216 | onDateSaved: (DateTime date) => inputDate = date, |
217 | selectableDayPredicate: (DateTime date) => date.day.isEven, |
218 | formKey: formKey, |
219 | ), |
220 | ); |
221 | // Default errorInvalid text |
222 | expect(find.text('Out of range.' ), findsNothing); |
223 | // Odd day shouldn't be valid |
224 | await tester.enterText(find.byType(TextField), '02/21/1966' ); |
225 | expect(formKey.currentState!.validate(), isFalse); |
226 | await tester.pumpAndSettle(); |
227 | expect(inputDate, isNull); |
228 | expect(find.text('Out of range.' ), findsOneWidget); |
229 | // Even day is valid |
230 | await tester.enterText(find.byType(TextField), '02/24/2030' ); |
231 | expect(formKey.currentState!.validate(), isTrue); |
232 | formKey.currentState!.save(); |
233 | await tester.pumpAndSettle(); |
234 | expect(inputDate, equals(DateTime(2030, DateTime.february, 24))); |
235 | expect(find.text('Out of range.' ), findsNothing); |
236 | }, |
237 | ); |
238 | |
239 | testWidgets('Empty field shows hint text when focused' , (WidgetTester tester) async { |
240 | await tester.pumpWidget(inputDatePickerField()); |
241 | // Focus on it |
242 | await tester.tap(find.byType(TextField)); |
243 | await tester.pumpAndSettle(); |
244 | |
245 | // Hint text should be invisible |
246 | expect(textOpacity(tester, 'mm/dd/yyyy' ), equals(0.0)); |
247 | textFieldController(tester).clear(); |
248 | await tester.pumpAndSettle(); |
249 | // Hint text should be visible |
250 | expect(textOpacity(tester, 'mm/dd/yyyy' ), equals(1.0)); |
251 | |
252 | // Change to a different hint text |
253 | await tester.pumpWidget(inputDatePickerField(fieldHintText: 'Enter some date' )); |
254 | await tester.pumpAndSettle(); |
255 | expect(find.text('mm/dd/yyyy' ), findsNothing); |
256 | expect(textOpacity(tester, 'Enter some date' ), equals(1.0)); |
257 | await tester.enterText(find.byType(TextField), 'foobar' ); |
258 | await tester.pumpAndSettle(); |
259 | expect(textOpacity(tester, 'Enter some date' ), equals(0.0)); |
260 | }); |
261 | |
262 | testWidgets('Label text' , (WidgetTester tester) async { |
263 | await tester.pumpWidget(inputDatePickerField()); |
264 | // Default label |
265 | expect(find.text('Enter Date' ), findsOneWidget); |
266 | |
267 | await tester.pumpWidget(inputDatePickerField(fieldLabelText: 'Give me a date!' )); |
268 | expect(find.text('Enter Date' ), findsNothing); |
269 | expect(find.text('Give me a date!' ), findsOneWidget); |
270 | }); |
271 | |
272 | testWidgets('Semantics' , (WidgetTester tester) async { |
273 | final SemanticsHandle semantics = tester.ensureSemantics(); |
274 | |
275 | // Fill the clipboard so that the Paste option is available in the text |
276 | // selection menu. |
277 | tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( |
278 | SystemChannels.platform, |
279 | mockClipboard.handleMethodCall, |
280 | ); |
281 | await Clipboard.setData(const ClipboardData(text: 'Clipboard data' )); |
282 | addTearDown( |
283 | () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( |
284 | SystemChannels.platform, |
285 | null, |
286 | ), |
287 | ); |
288 | |
289 | await tester.pumpWidget(inputDatePickerField(autofocus: true)); |
290 | await tester.pumpAndSettle(); |
291 | |
292 | expect( |
293 | tester.getSemantics(find.byType(EditableText)), |
294 | matchesSemantics( |
295 | label: 'Enter Date' , |
296 | isTextField: true, |
297 | hasEnabledState: true, |
298 | isEnabled: true, |
299 | isFocused: true, |
300 | value: '01/15/2016' , |
301 | hasTapAction: true, |
302 | hasFocusAction: true, |
303 | hasSetTextAction: true, |
304 | hasSetSelectionAction: true, |
305 | hasCopyAction: true, |
306 | hasCutAction: true, |
307 | hasPasteAction: true, |
308 | hasMoveCursorBackwardByCharacterAction: true, |
309 | hasMoveCursorBackwardByWordAction: true, |
310 | validationResult: SemanticsValidationResult.valid, |
311 | ), |
312 | ); |
313 | semantics.dispose(); |
314 | }); |
315 | |
316 | testWidgets('InputDecorationTheme is honored' , (WidgetTester tester) async { |
317 | const InputBorder border = InputBorder.none; |
318 | await tester.pumpWidget( |
319 | inputDatePickerField( |
320 | theme: ThemeData.from( |
321 | colorScheme: const ColorScheme.light(), |
322 | ).copyWith(inputDecorationTheme: const InputDecorationTheme(border: border)), |
323 | ), |
324 | ); |
325 | await tester.pumpAndSettle(); |
326 | |
327 | // Get the border and container color from the painter of the _BorderContainer |
328 | // (this was cribbed from input_decorator_test.dart). |
329 | final CustomPaint customPaint = tester.widget( |
330 | find.descendant( |
331 | of: find.byWidgetPredicate((Widget w) => ' ${w.runtimeType}' == '_BorderContainer' ), |
332 | matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), |
333 | ), |
334 | ); |
335 | final dynamic /*_InputBorderPainter*/ inputBorderPainter = customPaint.foregroundPainter; |
336 | // ignore: avoid_dynamic_calls |
337 | final dynamic /*_InputBorderTween*/ inputBorderTween = inputBorderPainter.border; |
338 | // ignore: avoid_dynamic_calls |
339 | final Animation<double> animation = inputBorderPainter.borderAnimation as Animation<double>; |
340 | // ignore: avoid_dynamic_calls |
341 | final InputBorder actualBorder = inputBorderTween.evaluate(animation) as InputBorder; |
342 | // ignore: avoid_dynamic_calls |
343 | final Color containerColor = inputBorderPainter.blendedColor as Color; |
344 | |
345 | // Border should match |
346 | expect(actualBorder, equals(border)); |
347 | |
348 | // It shouldn't be filled, so the color should be transparent |
349 | expect(containerColor, equals(Colors.transparent)); |
350 | }); |
351 | |
352 | testWidgets('Date text localization' , (WidgetTester tester) async { |
353 | final Iterable<LocalizationsDelegate<dynamic>> delegates = <LocalizationsDelegate<dynamic>>[ |
354 | TestMaterialLocalizationsDelegate(), |
355 | DefaultWidgetsLocalizations.delegate, |
356 | ]; |
357 | await tester.pumpWidget(inputDatePickerField(localizationsDelegates: delegates)); |
358 | await tester.enterText(find.byType(TextField), '01/01/2022' ); |
359 | await tester.pumpAndSettle(); |
360 | |
361 | // Verify that the widget can be updated to a new value after the |
362 | // entered text was transformed by the localization formatter. |
363 | await tester.pumpWidget( |
364 | inputDatePickerField(initialDate: DateTime(2017), localizationsDelegates: delegates), |
365 | ); |
366 | }); |
367 | |
368 | testWidgets( |
369 | 'when an empty date is entered and acceptEmptyDate is true, then errorFormatText is not shown' , |
370 | (WidgetTester tester) async { |
371 | final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
372 | const String errorFormatText = 'That is not a date.' ; |
373 | await tester.pumpWidget( |
374 | inputDatePickerField( |
375 | errorFormatText: errorFormatText, |
376 | formKey: formKey, |
377 | acceptEmptyDate: true, |
378 | ), |
379 | ); |
380 | await tester.enterText(find.byType(TextField), '' ); |
381 | await tester.pumpAndSettle(); |
382 | formKey.currentState!.validate(); |
383 | await tester.pumpAndSettle(); |
384 | expect(find.text(errorFormatText), findsNothing); |
385 | }, |
386 | ); |
387 | |
388 | testWidgets( |
389 | 'when an empty date is entered and acceptEmptyDate is false, then errorFormatText is shown' , |
390 | (WidgetTester tester) async { |
391 | final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
392 | const String errorFormatText = 'That is not a date.' ; |
393 | await tester.pumpWidget( |
394 | inputDatePickerField(errorFormatText: errorFormatText, formKey: formKey), |
395 | ); |
396 | await tester.enterText(find.byType(TextField), '' ); |
397 | await tester.pumpAndSettle(); |
398 | formKey.currentState!.validate(); |
399 | await tester.pumpAndSettle(); |
400 | expect(find.text(errorFormatText), findsOneWidget); |
401 | }, |
402 | ); |
403 | }); |
404 | |
405 | testWidgets('FocusNode can request focus' , (WidgetTester tester) async { |
406 | final FocusNode focusNode = FocusNode(); |
407 | addTearDown(focusNode.dispose); |
408 | await tester.pumpWidget(inputDatePickerField(focusNode: focusNode)); |
409 | expect((tester.widget(find.byType(TextField)) as TextField).focusNode, focusNode); |
410 | expect(focusNode.hasFocus, isFalse); |
411 | focusNode.requestFocus(); |
412 | await tester.pumpAndSettle(); |
413 | expect(focusNode.hasFocus, isTrue); |
414 | focusNode.unfocus(); |
415 | await tester.pumpAndSettle(); |
416 | expect(focusNode.hasFocus, isFalse); |
417 | }); |
418 | |
419 | group('Calendar Delegate' , () { |
420 | testWidgets('Defaults to Gregorian calendar system' , (WidgetTester tester) async { |
421 | await tester.pumpWidget( |
422 | MaterialApp( |
423 | theme: ThemeData(useMaterial3: true), |
424 | home: Material( |
425 | child: InputDatePickerFormField( |
426 | initialDate: DateTime(2025, DateTime.february, 26), |
427 | firstDate: DateTime(2025, DateTime.february), |
428 | lastDate: DateTime(2026, DateTime.may), |
429 | ), |
430 | ), |
431 | ), |
432 | ); |
433 | |
434 | final InputDatePickerFormField inputDatePickerField = tester.widget( |
435 | find.byType(InputDatePickerFormField), |
436 | ); |
437 | expect(inputDatePickerField.calendarDelegate, isA<GregorianCalendarDelegate>()); |
438 | }); |
439 | |
440 | testWidgets('Using custom calendar delegate implementation' , (WidgetTester tester) async { |
441 | await tester.pumpWidget( |
442 | MaterialApp( |
443 | theme: ThemeData(useMaterial3: true), |
444 | home: Material( |
445 | child: InputDatePickerFormField( |
446 | initialDate: DateTime(2025, DateTime.february, 26), |
447 | firstDate: DateTime(2025, DateTime.february), |
448 | lastDate: DateTime(2026, DateTime.may), |
449 | calendarDelegate: const TestCalendarDelegate(), |
450 | ), |
451 | ), |
452 | ), |
453 | ); |
454 | |
455 | final InputDatePickerFormField inputDatePickerField = tester.widget( |
456 | find.byType(InputDatePickerFormField), |
457 | ); |
458 | expect(inputDatePickerField.calendarDelegate, isA<TestCalendarDelegate>()); |
459 | }); |
460 | |
461 | testWidgets('Displays calendar based on the calendar delegate' , (WidgetTester tester) async { |
462 | DateTime? selectedDate; |
463 | |
464 | await tester.pumpWidget( |
465 | MaterialApp( |
466 | theme: ThemeData(useMaterial3: true), |
467 | home: Material( |
468 | child: InputDatePickerFormField( |
469 | initialDate: DateTime(2025, DateTime.february, 26), |
470 | firstDate: DateTime(2025, DateTime.february), |
471 | lastDate: DateTime(2026, DateTime.may), |
472 | onDateSubmitted: (DateTime value) { |
473 | selectedDate = value; |
474 | }, |
475 | calendarDelegate: const TestCalendarDelegate(), |
476 | ), |
477 | ), |
478 | ), |
479 | ); |
480 | |
481 | final Finder dateInput1 = find.descendant( |
482 | of: find.byType(TextField), |
483 | matching: find.text('2025..2..26' ), |
484 | ); |
485 | expect(dateInput1, findsOneWidget); |
486 | |
487 | await tester.tap(dateInput1); |
488 | await tester.pumpAndSettle(); |
489 | |
490 | await tester.enterText(dateInput1, '2025..3..10' ); |
491 | await tester.testTextInput.receiveAction(TextInputAction.done); |
492 | await tester.pumpAndSettle(); |
493 | |
494 | expect(selectedDate, DateTime(2025, DateTime.march, 10)); |
495 | |
496 | final Finder dateInput2 = find.descendant( |
497 | of: find.byType(TextField), |
498 | matching: find.text('2025..3..10' ), |
499 | ); |
500 | expect(dateInput2, findsOneWidget); |
501 | |
502 | await tester.tap(dateInput2); |
503 | await tester.pumpAndSettle(); |
504 | |
505 | await tester.enterText(dateInput2, '2025..4..21' ); |
506 | await tester.testTextInput.receiveAction(TextInputAction.done); |
507 | await tester.pumpAndSettle(); |
508 | |
509 | expect(selectedDate, DateTime(2025, DateTime.april, 21)); |
510 | }); |
511 | }); |
512 | } |
513 | |
514 | class TestCalendarDelegate extends GregorianCalendarDelegate { |
515 | const TestCalendarDelegate(); |
516 | |
517 | @override |
518 | String formatCompactDate(DateTime date, MaterialLocalizations localizations) { |
519 | return ' ${date.year}.. ${date.month}.. ${date.day}' ; |
520 | } |
521 | |
522 | @override |
523 | DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { |
524 | final List<String> parts = inputString!.split('..' ); |
525 | if (parts.length != 3) { |
526 | return null; |
527 | } |
528 | final int year = int.tryParse(parts[0]) ?? 0; |
529 | final int month = int.tryParse(parts[1]) ?? 0; |
530 | final int day = int.tryParse(parts[2]) ?? 0; |
531 | return DateTime(year, month, day); |
532 | } |
533 | |
534 | @override |
535 | String dateHelpText(MaterialLocalizations localizations) { |
536 | return 'yyyy..mm..dd' ; |
537 | } |
538 | } |
539 | |