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/gestures.dart' show DragStartBehavior; |
6 | import 'package:flutter/material.dart'; |
7 | import 'package:flutter/services.dart'; |
8 | |
9 | import '../../gallery/demo.dart'; |
10 | |
11 | class TextFormFieldDemo extends StatefulWidget { |
12 | const TextFormFieldDemo({super.key}); |
13 | |
14 | static const String routeName = '/material/text-form-field' ; |
15 | |
16 | @override |
17 | TextFormFieldDemoState createState() => TextFormFieldDemoState(); |
18 | } |
19 | |
20 | class PersonData { |
21 | String? name = '' ; |
22 | String? phoneNumber = '' ; |
23 | String? email = '' ; |
24 | String password = '' ; |
25 | } |
26 | |
27 | class PasswordField extends StatefulWidget { |
28 | const PasswordField({ |
29 | super.key, |
30 | this.fieldKey, |
31 | this.hintText, |
32 | this.labelText, |
33 | this.helperText, |
34 | this.onSaved, |
35 | this.validator, |
36 | this.onFieldSubmitted, |
37 | }); |
38 | |
39 | final Key? fieldKey; |
40 | final String? hintText; |
41 | final String? labelText; |
42 | final String? helperText; |
43 | final FormFieldSetter<String>? onSaved; |
44 | final FormFieldValidator<String>? validator; |
45 | final ValueChanged<String>? onFieldSubmitted; |
46 | |
47 | @override |
48 | State<PasswordField> createState() => _PasswordFieldState(); |
49 | } |
50 | |
51 | class _PasswordFieldState extends State<PasswordField> { |
52 | bool _obscureText = true; |
53 | |
54 | @override |
55 | Widget build(BuildContext context) { |
56 | return TextFormField( |
57 | key: widget.fieldKey, |
58 | obscureText: _obscureText, |
59 | maxLength: 8, |
60 | onSaved: widget.onSaved, |
61 | validator: widget.validator, |
62 | onFieldSubmitted: widget.onFieldSubmitted, |
63 | decoration: InputDecoration( |
64 | border: const UnderlineInputBorder(), |
65 | filled: true, |
66 | hintText: widget.hintText, |
67 | labelText: widget.labelText, |
68 | helperText: widget.helperText, |
69 | suffixIcon: GestureDetector( |
70 | dragStartBehavior: DragStartBehavior.down, |
71 | onTap: () { |
72 | setState(() { |
73 | _obscureText = !_obscureText; |
74 | }); |
75 | }, |
76 | child: Icon( |
77 | _obscureText ? Icons.visibility : Icons.visibility_off, |
78 | semanticLabel: _obscureText ? 'show password' : 'hide password' , |
79 | ), |
80 | ), |
81 | ), |
82 | ); |
83 | } |
84 | } |
85 | |
86 | class TextFormFieldDemoState extends State<TextFormFieldDemo> { |
87 | PersonData person = PersonData(); |
88 | |
89 | void showInSnackBar(String value) { |
90 | ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(value))); |
91 | } |
92 | |
93 | AutovalidateMode _autovalidateMode = AutovalidateMode.disabled; |
94 | bool _formWasEdited = false; |
95 | |
96 | final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); |
97 | final GlobalKey<FormFieldState<String>> _passwordFieldKey = GlobalKey<FormFieldState<String>>(); |
98 | final _UsNumberTextInputFormatter _phoneNumberFormatter = _UsNumberTextInputFormatter(); |
99 | void _handleSubmitted() { |
100 | final FormState form = _formKey.currentState!; |
101 | if (!form.validate()) { |
102 | _autovalidateMode = AutovalidateMode.always; // Start validating on every change. |
103 | showInSnackBar('Please fix the errors in red before submitting.' ); |
104 | } else { |
105 | form.save(); |
106 | showInSnackBar(" ${person.name}'s phone number is ${person.phoneNumber}" ); |
107 | } |
108 | } |
109 | |
110 | String? _validateName(String? value) { |
111 | _formWasEdited = true; |
112 | if (value!.isEmpty) { |
113 | return 'Name is required.' ; |
114 | } |
115 | final RegExp nameExp = RegExp(r'^[A-Za-z ]+$' ); |
116 | if (!nameExp.hasMatch(value)) { |
117 | return 'Please enter only alphabetical characters.' ; |
118 | } |
119 | return null; |
120 | } |
121 | |
122 | String? _validatePhoneNumber(String? value) { |
123 | _formWasEdited = true; |
124 | final RegExp phoneExp = RegExp(r'^\(\d\d\d\) \d\d\d\-\d\d\d\d$' ); |
125 | if (!phoneExp.hasMatch(value!)) { |
126 | return '(###) ###-#### - Enter a US phone number.' ; |
127 | } |
128 | return null; |
129 | } |
130 | |
131 | String? _validatePassword(String? value) { |
132 | _formWasEdited = true; |
133 | final FormFieldState<String> passwordField = _passwordFieldKey.currentState!; |
134 | if (passwordField.value == null || passwordField.value!.isEmpty) { |
135 | return 'Please enter a password.' ; |
136 | } |
137 | if (passwordField.value != value) { |
138 | return "The passwords don't match" ; |
139 | } |
140 | return null; |
141 | } |
142 | |
143 | Future<void> _handlePopInvoked(bool didPop, Object? result) async { |
144 | if (didPop) { |
145 | return; |
146 | } |
147 | |
148 | final bool? result = await showDialog<bool>( |
149 | context: context, |
150 | builder: (BuildContext context) { |
151 | return AlertDialog( |
152 | title: const Text('This form has errors' ), |
153 | content: const Text('Really leave this form?' ), |
154 | actions: <Widget>[ |
155 | TextButton( |
156 | child: const Text('YES' ), |
157 | onPressed: () { |
158 | Navigator.of(context).pop(true); |
159 | }, |
160 | ), |
161 | TextButton( |
162 | child: const Text('NO' ), |
163 | onPressed: () { |
164 | Navigator.of(context).pop(false); |
165 | }, |
166 | ), |
167 | ], |
168 | ); |
169 | }, |
170 | ); |
171 | |
172 | if (result ?? false) { |
173 | // Since this is the root route, quit the app where possible by invoking |
174 | // the SystemNavigator. If this wasn't the root route, then |
175 | // Navigator.maybePop could be used instead. |
176 | // See https://github.com/flutter/flutter/issues/11490 |
177 | SystemNavigator.pop(); |
178 | } |
179 | } |
180 | |
181 | @override |
182 | Widget build(BuildContext context) { |
183 | return Scaffold( |
184 | drawerDragStartBehavior: DragStartBehavior.down, |
185 | appBar: AppBar( |
186 | title: const Text('Text fields' ), |
187 | actions: <Widget>[MaterialDemoDocumentationButton(TextFormFieldDemo.routeName)], |
188 | ), |
189 | body: SafeArea( |
190 | top: false, |
191 | bottom: false, |
192 | child: Form( |
193 | key: _formKey, |
194 | autovalidateMode: _autovalidateMode, |
195 | canPop: |
196 | _formKey.currentState == null || !_formWasEdited || _formKey.currentState!.validate(), |
197 | onPopInvokedWithResult: _handlePopInvoked, |
198 | child: Scrollbar( |
199 | child: SingleChildScrollView( |
200 | primary: true, |
201 | dragStartBehavior: DragStartBehavior.down, |
202 | padding: const EdgeInsets.symmetric(horizontal: 16.0), |
203 | child: Column( |
204 | crossAxisAlignment: CrossAxisAlignment.stretch, |
205 | children: <Widget>[ |
206 | const SizedBox(height: 24.0), |
207 | TextFormField( |
208 | textCapitalization: TextCapitalization.words, |
209 | decoration: const InputDecoration( |
210 | border: UnderlineInputBorder(), |
211 | filled: true, |
212 | icon: Icon(Icons.person), |
213 | hintText: 'What do people call you?' , |
214 | labelText: 'Name *' , |
215 | ), |
216 | onSaved: (String? value) { |
217 | person.name = value; |
218 | }, |
219 | validator: _validateName, |
220 | ), |
221 | const SizedBox(height: 24.0), |
222 | TextFormField( |
223 | decoration: const InputDecoration( |
224 | border: UnderlineInputBorder(), |
225 | filled: true, |
226 | icon: Icon(Icons.phone), |
227 | hintText: 'Where can we reach you?' , |
228 | labelText: 'Phone Number *' , |
229 | prefixText: '+1' , |
230 | ), |
231 | keyboardType: TextInputType.phone, |
232 | onSaved: (String? value) { |
233 | person.phoneNumber = value; |
234 | }, |
235 | validator: _validatePhoneNumber, |
236 | // TextInputFormatters are applied in sequence. |
237 | inputFormatters: <TextInputFormatter>[ |
238 | FilteringTextInputFormatter.digitsOnly, |
239 | // Fit the validating format. |
240 | _phoneNumberFormatter, |
241 | ], |
242 | ), |
243 | const SizedBox(height: 24.0), |
244 | TextFormField( |
245 | decoration: const InputDecoration( |
246 | border: UnderlineInputBorder(), |
247 | filled: true, |
248 | icon: Icon(Icons.email), |
249 | hintText: 'Your email address' , |
250 | labelText: 'E-mail' , |
251 | ), |
252 | keyboardType: TextInputType.emailAddress, |
253 | onSaved: (String? value) { |
254 | person.email = value; |
255 | }, |
256 | ), |
257 | const SizedBox(height: 24.0), |
258 | TextFormField( |
259 | decoration: const InputDecoration( |
260 | border: OutlineInputBorder(), |
261 | hintText: |
262 | 'Tell us about yourself (e.g., write down what you do or what hobbies you have)' , |
263 | helperText: 'Keep it short, this is just a demo.' , |
264 | labelText: 'Life story' , |
265 | ), |
266 | maxLines: 3, |
267 | ), |
268 | const SizedBox(height: 24.0), |
269 | TextFormField( |
270 | keyboardType: TextInputType.number, |
271 | decoration: const InputDecoration( |
272 | border: OutlineInputBorder(), |
273 | labelText: 'Salary' , |
274 | prefixText: r'$' , |
275 | suffixText: 'USD' , |
276 | suffixStyle: TextStyle(color: Colors.green), |
277 | ), |
278 | ), |
279 | const SizedBox(height: 24.0), |
280 | PasswordField( |
281 | fieldKey: _passwordFieldKey, |
282 | helperText: 'No more than 8 characters.' , |
283 | labelText: 'Password *' , |
284 | onFieldSubmitted: (String value) { |
285 | setState(() { |
286 | person.password = value; |
287 | }); |
288 | }, |
289 | ), |
290 | const SizedBox(height: 24.0), |
291 | TextFormField( |
292 | enabled: person.password.isNotEmpty, |
293 | decoration: const InputDecoration( |
294 | border: UnderlineInputBorder(), |
295 | filled: true, |
296 | labelText: 'Re-type password' , |
297 | ), |
298 | maxLength: 8, |
299 | obscureText: true, |
300 | validator: _validatePassword, |
301 | ), |
302 | const SizedBox(height: 24.0), |
303 | Center( |
304 | child: ElevatedButton(onPressed: _handleSubmitted, child: const Text('SUBMIT' )), |
305 | ), |
306 | const SizedBox(height: 24.0), |
307 | Text('* indicates required field' , style: Theme.of(context).textTheme.bodySmall), |
308 | const SizedBox(height: 24.0), |
309 | ], |
310 | ), |
311 | ), |
312 | ), |
313 | ), |
314 | ), |
315 | ); |
316 | } |
317 | } |
318 | |
319 | /// Format incoming numeric text to fit the format of (###) ###-#### ##... |
320 | class _UsNumberTextInputFormatter extends TextInputFormatter { |
321 | @override |
322 | TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { |
323 | final int newTextLength = newValue.text.length; |
324 | int selectionIndex = newValue.selection.end; |
325 | int usedSubstringIndex = 0; |
326 | final StringBuffer newText = StringBuffer(); |
327 | if (newTextLength >= 1) { |
328 | newText.write('(' ); |
329 | if (newValue.selection.end >= 1) { |
330 | selectionIndex++; |
331 | } |
332 | } |
333 | if (newTextLength >= 4) { |
334 | final String value = newValue.text.substring(0, usedSubstringIndex = 3); |
335 | newText.write(' $value) ' ); |
336 | if (newValue.selection.end >= 3) { |
337 | selectionIndex += 2; |
338 | } |
339 | } |
340 | if (newTextLength >= 7) { |
341 | final String value = newValue.text.substring(3, usedSubstringIndex = 6); |
342 | newText.write(' $value-' ); |
343 | if (newValue.selection.end >= 6) { |
344 | selectionIndex++; |
345 | } |
346 | } |
347 | if (newTextLength >= 11) { |
348 | final String value = newValue.text.substring(6, usedSubstringIndex = 10); |
349 | newText.write(' $value ' ); |
350 | if (newValue.selection.end >= 10) { |
351 | selectionIndex++; |
352 | } |
353 | } |
354 | // Dump the rest. |
355 | if (newTextLength >= usedSubstringIndex) { |
356 | newText.write(newValue.text.substring(usedSubstringIndex)); |
357 | } |
358 | return TextEditingValue( |
359 | text: newText.toString(), |
360 | selection: TextSelection.collapsed(offset: selectionIndex), |
361 | ); |
362 | } |
363 | } |
364 | |