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 | /// @docImport 'date_picker.dart'; |
6 | /// @docImport 'text_field.dart'; |
7 | library; |
8 | |
9 | import 'package:flutter/widgets.dart'; |
10 | |
11 | import 'date.dart'; |
12 | import 'date_picker_theme.dart'; |
13 | import 'input_border.dart'; |
14 | import 'input_decorator.dart'; |
15 | import 'material_localizations.dart'; |
16 | import 'text_form_field.dart'; |
17 | import 'theme.dart'; |
18 | |
19 | /// A [TextFormField] configured to accept and validate a date entered by a user. |
20 | /// |
21 | /// When the field is saved or submitted, the text will be parsed into a |
22 | /// [DateTime] according to the ambient locale's compact date format. If the |
23 | /// input text doesn't parse into a date, the [errorFormatText] message will |
24 | /// be displayed under the field. |
25 | /// |
26 | /// [firstDate], [lastDate], and [selectableDayPredicate] provide constraints on |
27 | /// what days are valid. If the input date isn't in the date range or doesn't pass |
28 | /// the given predicate, then the [errorInvalidText] message will be displayed |
29 | /// under the field. |
30 | /// |
31 | /// See also: |
32 | /// |
33 | /// * [showDatePicker], which shows a dialog that contains a Material Design |
34 | /// date picker which includes support for text entry of dates. |
35 | /// * [MaterialLocalizations.parseCompactDate], which is used to parse the text |
36 | /// input into a [DateTime]. |
37 | /// |
38 | class InputDatePickerFormField extends StatefulWidget { |
39 | /// Creates a [TextFormField] configured to accept and validate a date. |
40 | /// |
41 | /// If the optional [initialDate] is provided, then it will be used to populate |
42 | /// the text field. If the [fieldHintText] is provided, it will be shown. |
43 | /// |
44 | /// If [initialDate] is provided, it must not be before [firstDate] or after |
45 | /// [lastDate]. If [selectableDayPredicate] is provided, it must return `true` |
46 | /// for [initialDate]. |
47 | /// |
48 | /// [firstDate] must be on or before [lastDate]. |
49 | InputDatePickerFormField({ |
50 | super.key, |
51 | DateTime? initialDate, |
52 | required DateTime firstDate, |
53 | required DateTime lastDate, |
54 | this.onDateSubmitted, |
55 | this.onDateSaved, |
56 | this.selectableDayPredicate, |
57 | this.errorFormatText, |
58 | this.errorInvalidText, |
59 | this.fieldHintText, |
60 | this.fieldLabelText, |
61 | this.keyboardType, |
62 | this.autofocus = false, |
63 | this.acceptEmptyDate = false, |
64 | this.focusNode, |
65 | this.calendarDelegate = const GregorianCalendarDelegate(), |
66 | }) : initialDate = initialDate != null ? calendarDelegate.dateOnly(initialDate) : null, |
67 | firstDate = calendarDelegate.dateOnly(firstDate), |
68 | lastDate = calendarDelegate.dateOnly(lastDate) { |
69 | assert( |
70 | !this.lastDate.isBefore(this.firstDate), |
71 | 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.' , |
72 | ); |
73 | assert( |
74 | initialDate == null || !this.initialDate!.isBefore(this.firstDate), |
75 | 'initialDate ${this.initialDate} must be on or after firstDate ${this.firstDate}.' , |
76 | ); |
77 | assert( |
78 | initialDate == null || !this.initialDate!.isAfter(this.lastDate), |
79 | 'initialDate ${this.initialDate} must be on or before lastDate ${this.lastDate}.' , |
80 | ); |
81 | assert( |
82 | selectableDayPredicate == null || |
83 | initialDate == null || |
84 | selectableDayPredicate!(this.initialDate!), |
85 | 'Provided initialDate ${this.initialDate} must satisfy provided selectableDayPredicate.' , |
86 | ); |
87 | } |
88 | |
89 | /// If provided, it will be used as the default value of the field. |
90 | final DateTime? initialDate; |
91 | |
92 | /// The earliest allowable [DateTime] that the user can input. |
93 | final DateTime firstDate; |
94 | |
95 | /// The latest allowable [DateTime] that the user can input. |
96 | final DateTime lastDate; |
97 | |
98 | /// An optional method to call when the user indicates they are done editing |
99 | /// the text in the field. Will only be called if the input represents a valid |
100 | /// [DateTime]. |
101 | final ValueChanged<DateTime>? onDateSubmitted; |
102 | |
103 | /// An optional method to call with the final date when the form is |
104 | /// saved via [FormState.save]. Will only be called if the input represents |
105 | /// a valid [DateTime]. |
106 | final ValueChanged<DateTime>? onDateSaved; |
107 | |
108 | /// Function to provide full control over which [DateTime] can be selected. |
109 | final SelectableDayPredicate? selectableDayPredicate; |
110 | |
111 | /// The error text displayed if the entered date is not in the correct format. |
112 | final String? errorFormatText; |
113 | |
114 | /// The error text displayed if the date is not valid. |
115 | /// |
116 | /// A date is not valid if it is earlier than [firstDate], later than |
117 | /// [lastDate], or doesn't pass the [selectableDayPredicate]. |
118 | final String? errorInvalidText; |
119 | |
120 | /// The hint text displayed in the [TextField]. |
121 | /// |
122 | /// If this is null, it will default to the date format string. For example, |
123 | /// 'mm/dd/yyyy' for en_US. |
124 | final String? fieldHintText; |
125 | |
126 | /// The label text displayed in the [TextField]. |
127 | /// |
128 | /// If this is null, it will default to the words representing the date format |
129 | /// string. For example, 'Month, Day, Year' for en_US. |
130 | final String? fieldLabelText; |
131 | |
132 | /// The keyboard type of the [TextField]. |
133 | /// |
134 | /// If this is null, it will default to [TextInputType.datetime] |
135 | final TextInputType? keyboardType; |
136 | |
137 | /// {@macro flutter.widgets.editableText.autofocus} |
138 | final bool autofocus; |
139 | |
140 | /// Determines if an empty date would show [errorFormatText] or not. |
141 | /// |
142 | /// Defaults to false. |
143 | /// |
144 | /// If true, [errorFormatText] is not shown when the date input field is empty. |
145 | final bool acceptEmptyDate; |
146 | |
147 | /// {@macro flutter.widgets.Focus.focusNode} |
148 | final FocusNode? focusNode; |
149 | |
150 | /// {@macro flutter.material.calendar_date_picker.calendarDelegate} |
151 | final CalendarDelegate<DateTime> calendarDelegate; |
152 | |
153 | @override |
154 | State<InputDatePickerFormField> createState() => _InputDatePickerFormFieldState(); |
155 | } |
156 | |
157 | class _InputDatePickerFormFieldState extends State<InputDatePickerFormField> { |
158 | final TextEditingController _controller = TextEditingController(); |
159 | DateTime? _selectedDate; |
160 | String? _inputText; |
161 | bool _autoSelected = false; |
162 | |
163 | @override |
164 | void initState() { |
165 | super.initState(); |
166 | _selectedDate = widget.initialDate; |
167 | } |
168 | |
169 | @override |
170 | void dispose() { |
171 | _controller.dispose(); |
172 | super.dispose(); |
173 | } |
174 | |
175 | @override |
176 | void didChangeDependencies() { |
177 | super.didChangeDependencies(); |
178 | _updateValueForSelectedDate(); |
179 | } |
180 | |
181 | @override |
182 | void didUpdateWidget(InputDatePickerFormField oldWidget) { |
183 | super.didUpdateWidget(oldWidget); |
184 | if (widget.initialDate != oldWidget.initialDate) { |
185 | // Can't update the form field in the middle of a build, so do it next frame |
186 | WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) { |
187 | setState(() { |
188 | _selectedDate = widget.initialDate; |
189 | _updateValueForSelectedDate(); |
190 | }); |
191 | }, debugLabel: 'InputDatePickerFormField.update' ); |
192 | } |
193 | } |
194 | |
195 | void _updateValueForSelectedDate() { |
196 | if (_selectedDate != null) { |
197 | final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
198 | _inputText = widget.calendarDelegate.formatCompactDate(_selectedDate!, localizations); |
199 | TextEditingValue textEditingValue = TextEditingValue(text: _inputText!); |
200 | // Select the new text if we are auto focused and haven't selected the text before. |
201 | if (widget.autofocus && !_autoSelected) { |
202 | textEditingValue = textEditingValue.copyWith( |
203 | selection: TextSelection(baseOffset: 0, extentOffset: _inputText!.length), |
204 | ); |
205 | _autoSelected = true; |
206 | } |
207 | _controller.value = textEditingValue; |
208 | } else { |
209 | _inputText = '' ; |
210 | _controller.value = TextEditingValue(text: _inputText!); |
211 | } |
212 | } |
213 | |
214 | DateTime? _parseDate(String? text) { |
215 | final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
216 | return widget.calendarDelegate.parseCompactDate(text, localizations); |
217 | } |
218 | |
219 | bool _isValidAcceptableDate(DateTime? date) { |
220 | return date != null && |
221 | !date.isBefore(widget.firstDate) && |
222 | !date.isAfter(widget.lastDate) && |
223 | (widget.selectableDayPredicate == null || widget.selectableDayPredicate!(date)); |
224 | } |
225 | |
226 | String? _validateDate(String? text) { |
227 | if ((text == null || text.isEmpty) && widget.acceptEmptyDate) { |
228 | return null; |
229 | } |
230 | final DateTime? date = _parseDate(text); |
231 | if (date == null) { |
232 | return widget.errorFormatText ?? MaterialLocalizations.of(context).invalidDateFormatLabel; |
233 | } else if (!_isValidAcceptableDate(date)) { |
234 | return widget.errorInvalidText ?? MaterialLocalizations.of(context).dateOutOfRangeLabel; |
235 | } |
236 | return null; |
237 | } |
238 | |
239 | void _updateDate(String? text, ValueChanged<DateTime>? callback) { |
240 | final DateTime? date = _parseDate(text); |
241 | if (_isValidAcceptableDate(date)) { |
242 | _selectedDate = date; |
243 | _inputText = text; |
244 | callback?.call(_selectedDate!); |
245 | } |
246 | } |
247 | |
248 | void _handleSaved(String? text) { |
249 | _updateDate(text, widget.onDateSaved); |
250 | } |
251 | |
252 | void _handleSubmitted(String text) { |
253 | _updateDate(text, widget.onDateSubmitted); |
254 | } |
255 | |
256 | @override |
257 | Widget build(BuildContext context) { |
258 | final ThemeData theme = Theme.of(context); |
259 | final bool useMaterial3 = theme.useMaterial3; |
260 | final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
261 | final DatePickerThemeData datePickerTheme = theme.datePickerTheme; |
262 | final InputDecorationTheme inputTheme = theme.inputDecorationTheme; |
263 | final InputBorder effectiveInputBorder = |
264 | datePickerTheme.inputDecorationTheme?.border ?? |
265 | theme.inputDecorationTheme.border ?? |
266 | (useMaterial3 ? const OutlineInputBorder() : const UnderlineInputBorder()); |
267 | |
268 | return Semantics( |
269 | container: true, |
270 | child: TextFormField( |
271 | decoration: InputDecoration( |
272 | hintText: widget.fieldHintText ?? widget.calendarDelegate.dateHelpText(localizations), |
273 | labelText: widget.fieldLabelText ?? localizations.dateInputLabel, |
274 | ).applyDefaults( |
275 | inputTheme |
276 | .merge(datePickerTheme.inputDecorationTheme) |
277 | .copyWith(border: effectiveInputBorder), |
278 | ), |
279 | validator: _validateDate, |
280 | keyboardType: widget.keyboardType ?? TextInputType.datetime, |
281 | onSaved: _handleSaved, |
282 | onFieldSubmitted: _handleSubmitted, |
283 | autofocus: widget.autofocus, |
284 | controller: _controller, |
285 | focusNode: widget.focusNode, |
286 | ), |
287 | ); |
288 | } |
289 | } |
290 | |