Commit a7b28a3e authored by Matt Perry's avatar Matt Perry

Added a Form widget to manage multiple Input widgets.

parent 03830d56
......@@ -11,14 +11,16 @@ class TextFieldDemo extends StatefulWidget {
TextFieldDemoState createState() => new TextFieldDemoState();
}
class PersonData {
String name;
String phoneNumber;
String password;
}
class TextFieldDemoState extends State<TextFieldDemo> {
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
final List<InputValue> _inputs = <InputValue>[
InputValue.empty,
InputValue.empty,
InputValue.empty,
InputValue.empty,
];
PersonData person = new PersonData();
void showInSnackBar(String value) {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
......@@ -26,36 +28,30 @@ class TextFieldDemoState extends State<TextFieldDemo> {
));
}
void _handleInputChanged(InputValue value, int which) {
setState(() {
_inputs[which] = value;
});
}
void _handleInputSubmitted(InputValue value) {
showInSnackBar('${_inputs[0].text}\'s phone number is ${_inputs[1].text}');
void _handleSubmitted() {
showInSnackBar('${person.name}\'s phone number is ${person.phoneNumber}');
}
String _validateName(InputValue value) {
if (value.text.isEmpty)
String _validateName(String value) {
if (value.isEmpty)
return 'Name is required.';
RegExp nameExp = new RegExp(r'^[A-za-z ]+$');
if (!nameExp.hasMatch(value.text))
if (!nameExp.hasMatch(value))
return 'Please enter only alphabetical characters.';
return null;
}
String _validatePhoneNumber(InputValue value) {
String _validatePhoneNumber(String value) {
RegExp phoneExp = new RegExp(r'^\d\d\d-\d\d\d\-\d\d\d\d$');
if (!phoneExp.hasMatch(value.text))
if (!phoneExp.hasMatch(value))
return '###-###-#### - Please enter a valid phone number.';
return null;
}
String _validatePassword(InputValue value1, InputValue value2) {
if (value1.text.isEmpty)
String _validatePassword(String value) {
if (person.password == null || person.password.isEmpty)
return 'Please choose a password.';
if (value1.text != value2.text)
if (person.password != value)
return 'Passwords don\'t match';
return null;
}
......@@ -67,52 +63,55 @@ class TextFieldDemoState extends State<TextFieldDemo> {
appBar: new AppBar(
title: new Text('Text Fields')
),
body: new Block(
padding: const EdgeInsets.all(8.0),
children: <Widget>[
new Input(
hintText: 'What do people call you?',
labelText: 'Name',
errorText: _validateName(_inputs[0]),
value: _inputs[0],
onChanged: (InputValue value) { _handleInputChanged(value, 0); },
onSubmitted: _handleInputSubmitted
),
new Input(
hintText: 'Where can we reach you?',
labelText: 'Phone Number',
errorText: _validatePhoneNumber(_inputs[1]),
value: _inputs[1],
onChanged: (InputValue value) { _handleInputChanged(value, 1); },
onSubmitted: _handleInputSubmitted
),
new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Flexible(
child: new Input(
hintText: 'How do you log in?',
labelText: 'New Password',
hideText: true,
value: _inputs[2],
onChanged: (InputValue value) { _handleInputChanged(value, 2); },
onSubmitted: _handleInputSubmitted
)
),
new Flexible(
child: new Input(
hintText: 'How do you log in?',
labelText: 'Re-type Password',
errorText: _validatePassword(_inputs[2], _inputs[3]),
hideText: true,
value: _inputs[3],
onChanged: (InputValue value) { _handleInputChanged(value, 3); },
onSubmitted: _handleInputSubmitted
)
body: new Form(
onSubmitted: _handleSubmitted,
child: new Block(
padding: const EdgeInsets.all(8.0),
children: <Widget>[
new Input(
hintText: 'What do people call you?',
labelText: 'Name',
formField: new FormField<String>(
// TODO(mpcomplete): replace with person#name=
setter: (String val) { person.name = val; },
validator: _validateName
)
]
)
]
),
new Input(
hintText: 'Where can we reach you?',
labelText: 'Phone Number',
formField: new FormField<String>(
setter: (String val) { person.phoneNumber = val; },
validator: _validatePhoneNumber
)
),
new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Flexible(
child: new Input(
hintText: 'How do you log in?',
labelText: 'New Password',
hideText: true,
formField: new FormField<String>(
setter: (String val) { person.password = val; }
)
)
),
new Flexible(
child: new Input(
hintText: 'How do you log in?',
labelText: 'Re-type Password',
hideText: true,
formField: new FormField<String>(
validator: _validatePassword
)
)
)
]
)
]
)
)
);
}
......
......@@ -17,7 +17,7 @@ export 'package:sky_services/editing/editing.mojom.dart' show KeyboardType;
class Input extends StatefulWidget {
Input({
Key key,
this.value: InputValue.empty,
this.value,
this.keyboardType: KeyboardType.text,
this.icon,
this.labelText,
......@@ -27,6 +27,7 @@ class Input extends StatefulWidget {
this.hideText: false,
this.isDense: false,
this.autofocus: false,
this.formField,
this.onChanged,
this.onSubmitted
}) : super(key: key);
......@@ -61,6 +62,9 @@ class Input extends StatefulWidget {
/// Whether this input field should focus itself is nothing else is already focused.
final bool autofocus;
/// Form-specific data, required if this Input is part of a Form.
final FormField<String> formField;
/// Called when the text being edited changes.
final ValueChanged<InputValue> onChanged;
......@@ -79,12 +83,24 @@ class _InputState extends State<Input> {
GlobalKey get focusKey => config.key is GlobalKey ? config.key : _rawInputLineKey;
// Optional state to retain if we are inside a Form widget.
_FormFieldData _formData;
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
ThemeData themeData = Theme.of(context);
BuildContext focusContext = focusKey.currentContext;
bool focused = focusContext != null && Focus.at(focusContext, autofocus: config.autofocus);
if (_formData == null)
_formData = _FormFieldData.maybeCreate(context, this);
InputValue value = config.value ?? _formData?.value ?? InputValue.empty;
ValueChanged<InputValue> onChanged = config.onChanged ?? _formData?.onChanged;
ValueChanged<InputValue> onSubmitted = config.onSubmitted ?? _formData?.onSubmitted;
String errorText = config.errorText;
if (errorText == null && config.formField != null && config.formField.validator != null)
errorText = config.formField.validator(value.text);
TextStyle textStyle = config.style ?? themeData.textTheme.subhead;
Color activeColor = themeData.hintColor;
......@@ -102,7 +118,7 @@ class _InputState extends State<Input> {
List<Widget> stackChildren = <Widget>[];
bool hasInlineLabel = config.labelText != null && !focused && !config.value.text.isNotEmpty;
bool hasInlineLabel = config.labelText != null && !focused && !value.text.isNotEmpty;
if (config.labelText != null) {
TextStyle labelStyle = hasInlineLabel ?
......@@ -125,7 +141,7 @@ class _InputState extends State<Input> {
topPadding += topPaddingIncrement;
}
if (config.hintText != null && config.value.text.isEmpty && !hasInlineLabel) {
if (config.hintText != null && value.text.isEmpty && !hasInlineLabel) {
TextStyle hintStyle = themeData.textTheme.subhead.copyWith(color: themeData.hintColor);
stackChildren.add(new Positioned(
left: 0.0,
......@@ -139,7 +155,7 @@ class _InputState extends State<Input> {
Color borderColor = activeColor;
double borderWidth = focused ? 2.0 : 1.0;
if (config.errorText != null) {
if (errorText != null) {
borderColor = themeData.errorColor;
borderWidth = 2.0;
if (!config.isDense) {
......@@ -163,24 +179,24 @@ class _InputState extends State<Input> {
),
child: new RawInputLine(
key: _rawInputLineKey,
value: config.value,
value: value,
focusKey: focusKey,
style: textStyle,
hideText: config.hideText,
cursorColor: themeData.selectionColor,
selectionColor: themeData.selectionColor,
keyboardType: config.keyboardType,
onChanged: config.onChanged,
onSubmitted: config.onSubmitted
onChanged: onChanged,
onSubmitted: onSubmitted
)
));
if (config.errorText != null && !config.isDense) {
if (errorText != null && !config.isDense) {
TextStyle errorStyle = themeData.textTheme.caption.copyWith(color: themeData.errorColor);
stackChildren.add(new Positioned(
left: 0.0,
bottom: 0.0,
child: new Text(config.errorText, style: errorStyle)
child: new Text(errorText, style: errorStyle)
));
}
......@@ -216,3 +232,36 @@ class _InputState extends State<Input> {
);
}
}
class _FormFieldData {
_FormFieldData(this.inputState) {
assert(field != null);
}
InputValue value = new InputValue();
final _InputState inputState;
FormField<String> get field => inputState.config.formField;
static _FormFieldData maybeCreate(BuildContext context, _InputState inputState) {
// Only create a _FormFieldData if this Input is a descendent of a Form.
if (FormScope.of(context) != null)
return new _FormFieldData(inputState);
return null;
}
void onChanged(InputValue value) {
FormScope scope = FormScope.of(inputState.context);
assert(scope != null);
this.value = value;
if (field.setter != null)
field.setter(value.text);
scope.onFieldChanged();
}
void onSubmitted(InputValue value) {
FormScope scope = FormScope.of(inputState.context);
assert(scope != null);
scope.form.onSubmitted();
scope.onFieldChanged();
}
}
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'basic.dart';
import 'framework.dart';
/// A container for grouping together multiple form field widgets (e.g. Input).
class Form extends StatefulWidget {
Form({
Key key,
this.child,
this.onSubmitted
}) : super(key: key) {
assert(child != null);
}
/// Called when the input is accepted anywhere on the form.
final VoidCallback onSubmitted;
/// Root of the widget hierarchy that contains this form.
final Widget child;
@override
_FormState createState() => new _FormState();
}
class _FormState extends State<Form> {
int generation = 0;
void onFieldChanged() {
setState(() {
++generation;
});
}
@override
Widget build(BuildContext context) {
return new FormScope(
state: this,
generation: generation,
child: config.child
);
}
}
typedef String FormFieldValidator<T>(T value);
typedef void FormFieldSetter<T>(T newValue);
/// This contains identifying information for Input fields, required if the
/// Input is part of a Form.
class FormField<T> {
FormField({
this.setter,
this.validator
});
/// An optional method to call with the new value when the form field changes.
final FormFieldSetter<T> setter;
/// An optional method that validates an input. Returns an error string to
/// display if the input is invalid, or null otherwise.
final FormFieldValidator<T> validator;
}
/// The root of all Forms. Used by form field widgets (e.g. Input) to
/// communicate changes back to the client.
class FormScope extends InheritedWidget {
FormScope({
Key key,
Widget child,
_FormState state,
int generation
}) : _state = state,
_generation = generation,
super(key: key, child: child);
final _FormState _state;
/// Incremented every time a form field has changed. This lets us know when
/// to rebuild the form.
final int _generation;
/// The Form this widget belongs to.
Form get form => _state.config;
/// Finds the FormScope that encloses the widget being built from the given
/// context.
static FormScope of(BuildContext context) {
return context.inheritFromWidgetOfExactType(FormScope);
}
/// Use this to notify the Form that a form field has changed. This will
/// cause all form fields to rebuild, useful if form fields have
/// interdependencies.
void onFieldChanged() => _state.onFieldChanged();
@override
bool updateShouldNotify(FormScope old) => _generation != old._generation;
}
......@@ -17,6 +17,7 @@ export 'src/widgets/dismissable.dart';
export 'src/widgets/drag_target.dart';
export 'src/widgets/editable.dart';
export 'src/widgets/focus.dart';
export 'src/widgets/form.dart';
export 'src/widgets/framework.dart';
export 'src/widgets/gesture_detector.dart';
export 'src/widgets/gridpaper.dart';
......
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:sky_services/editing/editing.mojom.dart' as mojom;
import 'package:test/test.dart';
class MockKeyboard implements mojom.Keyboard {
mojom.KeyboardClient client;
@override
void setClient(mojom.KeyboardClientStub client, mojom.KeyboardConfiguration configuraiton) {
this.client = client.impl;
}
@override
void show() {}
@override
void hide() {}
@override
void setEditingState(mojom.EditingState state) {}
}
void main() {
WidgetFlutterBinding.ensureInitialized(); // for serviceMocker
MockKeyboard mockKeyboard = new MockKeyboard();
serviceMocker.registerMockService(mojom.Keyboard.serviceName, mockKeyboard);
void enterText(String testValue) {
// Simulate entry of text through the keyboard.
expect(mockKeyboard.client, isNotNull);
mockKeyboard.client.updateEditingState(new mojom.EditingState()
..text = testValue
..composingBase = 0
..composingExtent = testValue.length);
}
test('Setter callback is called', () {
testWidgets((WidgetTester tester) {
GlobalKey inputKey = new GlobalKey();
String fieldValue;
Widget builder() {
return new Center(
child: new Material(
child: new Form(
child: new Input(
key: inputKey,
formField: new FormField<String>(
setter: (String val) { fieldValue = val; }
)
)
)
)
);
}
tester.pumpWidget(builder());
void checkText(String testValue) {
enterText(testValue);
// Check that the FormField's setter was called.
expect(fieldValue, equals(testValue));
tester.pumpWidget(builder());
}
checkText('Test');
checkText('');
});
});
test('Validator sets the error text', () {
testWidgets((WidgetTester tester) {
GlobalKey inputKey = new GlobalKey();
String errorText(String input) => input + '/error';
Widget builder() {
return new Center(
child: new Material(
child: new Form(
child: new Input(
key: inputKey,
formField: new FormField<String>(
validator: errorText
)
)
)
)
);
}
tester.pumpWidget(builder());
void checkErrorText(String testValue) {
enterText(testValue);
tester.pumpWidget(builder());
// Check for a new Text widget with our error text.
Element errorElement = tester.findText(errorText(testValue));
expect(errorElement, isNotNull);
}
checkErrorText('Test');
checkErrorText('');
});
});
test('Multiple Inputs communicate', () {
testWidgets((WidgetTester tester) {
GlobalKey inputKey = new GlobalKey();
GlobalKey focusKey = new GlobalKey();
// Input 1's text value.
String fieldValue;
// Input 2's validator depends on a input 1's value.
String errorText(String input) => fieldValue.toString() + '/error';
Widget builder() {
return new Center(
child: new Material(
child: new Form(
child: new Focus(
key: focusKey,
child: new Block(
children: <Widget>[
new Input(
key: inputKey,
formField: new FormField<String>(
setter: (String val) { fieldValue = val; }
)
),
new Input(
formField: new FormField<String>(
validator: errorText
)
)
]
)
)
)
)
);
}
tester.pumpWidget(builder());
Focus.moveTo(inputKey);
tester.pump();
void checkErrorText(String testValue) {
enterText(testValue);
tester.pumpWidget(builder());
expect(fieldValue, equals(testValue));
// Check for a new Text widget with our error text.
Element errorElement = tester.findText(errorText(testValue));
expect(errorElement, isNotNull);
}
checkErrorText('Test');
checkErrorText('');
});
});
}
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