Port

Port

When building forms in Flutter, developers often encounter challenges such as manual form definition, validation logic, and UI consistency. The Port Module addresses these pain points by providing a powerful API for defining form structures, handling validation, and automatically generating user interface components.

Core Concepts

Port

A Port represents the definition of a form. It serves three primary purposes:

  1. Defining the fields of the form.
  2. Validating the form data.
  3. Transforming and returning the form values.

Here's an example of creating a Port:

final port = Port.of({
  'name': PortField.string().isNotBlank(),
  'email': PortField.string().isNotBlank().isEmail(),
});

In this example, the Port defines two fields: "name" and "email". The "name" field is a non-blank string, while the "email" field is a non-blank string that must be in a valid email format.

Fields

The Port Module provides a set of default PortField types to cover common form field scenarios:

  • PortField.string(): Represents a non-nullable String field.
  • PortField.int(): Represents an int field.
  • PortField.bool(): Represents a boolean field.
  • PortField.dateTime(): Represents a DateTime field.
  • PortField.option(options): Allows selecting a value from a predefined list of options.
  • PortField.embedded(port): Embeds another Port as a field, enabling nested form structures.
  • PortField.stage(options, portMapper): Provides a list of options and maps each option to a specific Port, allowing dynamic form stages based on user selections.
  • PortField.file(): Represents a CrossFile.

You can further customize PortFields by applying modifiers to add validation rules or transform the field data. Some common modifiers include:

  • .isNotNull(): Ensures the field value is not null.
  • .isNotBlank(): Ensures a String field is not null or blank.
  • .isEmail(): Validates that a String field is in a valid email format.
  • .isPhone(): Validates that a String field is in a valid phone number format.
  • .withAllowedFileTypes(): Validates that a cross file field has a valid file type.
  • .withFallback(fallback): Specifies a fallback value to use if the field value is null.
  • .map(mapper): Transforms the field value using a provided mapper function.
  • .withValidator(validator): Adds a custom validation rule using a Validator function.

Submitting and Validating

Once you have defined your Port and its fields, you can submit the form and validate the results. Here's an example of how to submit a Port and handle the validation results:

final port = Port.of({
  'name': PortField.string().isNotBlank(),
  'email': PortField.string().isNotBlank().isEmail(),
});
 
port.setValue(name: 'name', value: 'John Doe');
port.setValue(name: 'email', value: 'john@test.com');
 
final result = await port.submit();
result.isValid // true
result.data['name'] // 'John Doe'

Transforming Data

By default, a Port returns a Map<String, dynamic> containing the field names as keys and their corresponding values. However, you can transform the form data into a custom object using the .map(mapper) modifier. This allows you to create a Port that returns a specific type, making it more convenient to work with the form results.

final userPort = Port.of({
  'name': PortField.string().isNotBlank(),
  'email': PortField.string().isNotBlank().isEmail(),
}).map((values, port) => User(name: values['name'], email: values['email']));
 
port.setValue(name: 'name', value: 'John Doe');
port.setValue(name: 'email', value: 'john@test.com');
 
final userResult = await port.submit();
result.isValid // true
result.data // The mapped `User` object

Flutter Integration

The Port Module seamlessly integrates with Flutter, providing widgets and builders to simplify the rendering and management of form fields within your application.

PortBuilder

The PortBuilder widget listens to the values of a Port and rebuilds its child widgets whenever the form data changes. It provides access to the current state of the Port, allowing you to build custom widgets based on the form values.

PortBuilder(
  port: port,
  builder: (Port port) {
    // Build widgets based on the Port values
  }
)

PortFieldBuilder

The PortFieldBuilder widget focuses on a specific PortField within a Port. It listens to the value of the field and rebuilds its child widget whenever the field value changes. This is particularly useful for building custom field widgets or displaying field-specific error messages.

PortFieldBuilder(
  fieldName: 'name',
  builder: (context, portField, value, error) {
    return StyledText.body(value ?? 'Error: $error');
  }
)

Auto-generated UI

The Port Module takes form development to the next level by providing the ability to automatically generate user interface components based on a Port definition. This feature significantly reduces the amount of boilerplate code required to build form screens and ensures a consistent look and feel across your application.

The auto-generated UI considers the different PortField types, their modifiers, and any additional metadata provided. It intelligently maps each field to an appropriate UI component based on its type and characteristics, and also renders any validation errors underneath each field. For example:

  • PortField.string() is rendered as a text input field.
  • PortField.int() is rendered as a text input field with int validation.
  • PortField.double() is rendered as a text input field with double validation.
  • PortField.bool() is rendered as a checkbox.
  • PortField.dateTime() is rendered as a date picker.
  • PortField.option() is rendered as a dropdown.
  • PortField.crossFile() is rendered as a file input field.
  • PortField.stage(options, portMapper) is rendered as a multi-step form, where the user can select an option and dynamically load and view the corresponding Port UI.

The auto-generated UI also takes into account the modifiers applied to the fields. For instance:

  • .withDisplayName() adds a label above the field.
  • .withHint() adds a hint in the field.
  • .isNotNull() adds a required field indicator.
  • .multiline() sets a text field as multiline.
  • .isName() sets the TextInputType to TextInputType.name.
  • .isEmail() sets the TextInputType to TextInputType.email.
  • .isPhone() sets the TextInputType to TextInputType.phoneNumber and adds an input formatter for phone numbers.
  • .isSecret() masks the input as a password field.
  • .multiline() renders a multiline text input field.
  • .withAllowedFileTypes() sets the file picker to choose only files with the given file extensions.
  • .onlyImages() sets the file picker to choose only images.
  • .color() renders a color picker instead of a regular text input.
  • .withSuggestions(suggestionsGetter): Given the current value of the PortField, provides an asynchronous list of suggestions to display underneath the field.

The auto-generated UI aims to provide a functional and visually appealing form layout out of the box, saving developers time and effort in building form screens manually. However, it also offers flexibility through overrides and customization options, allowing developers to fine-tune the generated UI to match their specific design requirements.

Rendering a Port

You can render a Port in various ways:

  • As a StyledDialog: Use context.showDialog(StyledPortDialog(port: port)) to display the form fields in a dialog window. You can override the onAccept callback to customize the behavior when the user submits the form.
  • As a widget: Use StyledObjectPortBuilder(port: port) to render the form fields as a widget within your application. Use only to render only certain fields, and order to customize the order the fields will be rendered in.

Here's an example of how to automatically render a port:

final port = useMemoized(() => Port.of({
          'name': PortField.string().withDisplayName('Name').withHint('John Doe').isNotBlank().isName(),
          'email': PortField.string().withDisplayName('Email').isNotBlank().isEmail(),
          'password': PortField.string().withDisplayName('Password').isNotBlank().isSecret(),
          'bio': PortField.string().withDisplayName('Bio').multiline(),
          'age': PortField.int().withDisplayName('Age').isNotNull(),
          'favoriteColor': PortField.int().withDisplayName('Favorite Color').color(),
          'profilePicture': PortField.file().withDisplayName('Profile Picture').onlyImages(),
        }));
    return StyledPage(
      body: StyledList.column.withScrollbar(
        children: [
          StyledObjectPortBuilder(
            port: port,
          ),
        ],
      ),
    );
  }

Automatic UI Generation Demonstration

Overrides

If you need to customize the appearance or behavior of specific form fields, you can provide overrides and define the order of the generated widgets. This allows you to fine-tune the auto-generated UI to match your application's design and requirements.

StyledObjectPortBuilder(
  port: userPort, // A port that contains a 'name' and 'email' field.
  order: [ // Change the order of the rendered fields.
    'email',
    'name',
  ],
  overrides: {
    'email': StyledTextPortField(
      fieldName: 'email',
      leadingIcon: Icons.email, // Override the text field with a leading icon email.
    ),
  }
),