Skip to content

Prototype UI

Generating UI Constructs

All UI constructs are generated using the 'generate ui' subcommand. For example:

$ dart bin/xxx_afib.dart generate ui QuickStartScreen 
...
$ dart bin/xxx_afib.dart generate ui MainDrawer
...
$ dart bin/xxx_afib.dart generate ui EditItemDialog
...
$ dart bin/xxx_afib.dart generate ui SelectDateBottomSheet
...
$ dart bin/xxx_afib.dart genreate ui TodoItemWidget --member-variables "bool showDetails;" --resolve-variables "TodoItem"
...
All the UI types support the --member-variables and --resolve-variables flags, which add variables to the UI element's route parameter.

As with other generate commands, the final suffix of the construct's name determines what kind of UI element it is, and the form of the command correlates to the path of the resulting file:

$ dart bin/xxx_afib.dart generate ui QuickStartScreen

Yields

lib/ui/screens/quick_start_screen.dart

Screens vs. Widgets

The next few sections refer to 'screens', but are equally true for drawer, dialogs, and bottom sheets. Widgets are special because they are created by your code, not AFib itself. They are described in more detail in subsequent sections. I recommend you master using the top-level screen constructs before you begin using AFib-aware widgets.

Note that you can use traditional stateless Flutter widgets (many of the examples below do) and stateful Flutter widgets (with their state stored in your route parameter, explained below). Your widgets do not need to be AFib-aware, though as you use AFib more you will have cases where AFib-aware widgets are useful.

Efficiently Generating UI

All of the generate ui variants above take two optional parameters, --member-variables and --resolve-variables. These parameters work just as they do in the generate state command, creating member and resolve variables in the route parameter for the generated UI element.

For example, the command:

dart bin/tdle_afib.dart generate ui TodoDetailsScreen --member-variables "bool showDetails; String filter;" --resolve-variables "TodoItem todoItem;" 
will generate all of the code indicated in comments below:

class TodoDetailsScreenRouteParam extends AFScreenRouteParam {
  // it generates these member variables
  final bool showDetails;
  final String filter;
  final String todoItemId;

  TodoDetailsScreenRouteParam({
    // it generates these constructor parameters.
    required this.showDetails,
    required this.filter,
    required this.todoItemId,
  });

  // it generates this factory method, used by navigate push.
  factory TodoDetailsScreenRouteParam.create({
    required bool showDetails,
    required String filter,
    required String todoItemId,
  }) {
    return (
      TodoDetailsScrenRouteParam(
        showDetails: showDetails,
        filter: filter,
        todoItemId: todoItemId,
      );
    )
  }

  // it generates these revise methods
  TodoDetailsScreenRouteParam reviseShowDetails(bool value) => copyWith(showDetails: value);
  TodoDetailsScreenRouteParam reviseFilter(String value) => copyWith(filter: value);

  // it generates these resolve methods
  TodoItem? resolveTodoItem(TodoItemsRoot todoItems) => todoItems.findById(todoItemId);

  // It generates this copywith method
  TodoDetailsScreenRouteParam copyWith({
    bool? showDetails,
    String? filter,
    String? todoItemId,
  }) {
    return TodoDetailsScreenRouteParam(
      showDetails: showDetails,
      filter: filter,
      todoItemId: todoItemId,
    );
  }
}

class TodoDetailsScreenSPI extends ... {
  ...

  // it generates these accessor methods on the SPI
  bool get showDetails => context.p.showDetails;
  String get filter => context.p.filter;
  TodoItem? get todoItem => context.p.resolveTodoItem(context.s.todoItems);

  // it generates these event handlers on the SPI
  void onChangedShowDetails(bool value) {
    final revised = context.p.reviseShowDetails(value);
    context.updateRouteParam(value);
  }

  void onChangedFilter(String filter) {
    final revised = context.p.reviseFilter(filter);
    context.updateRouteParam(value);
  }

}

class TodoDetailsScreen extends ... {
  ...
  // it generates this navigate push method:

    AFNavigatePushAction navigatePush({
      required bool showDetails,
      required String filter,
      required String todoItemId,
    }) {
        return AFNavigatePushAction(
            launchParam: EditTodoItemScreenRouteParam.create(
                showDetails: showDetails,
                filter: filter,
                todoItemId: todoItemId,
            )
        );
    }

Using these two flags will save you a lot of time writing boilerplate, and will generally tend to preserve the coding conventions that I recommend.

Member Variables vs. Resolve Variables

The distinction between --member-variables and --resolve-variables is the same here as it was when generating state in the previous section.

If you want your route parameter to have a direct reference to an object, then place it in the --member-variables flag. If you want it to have an ID for an object, and resolve that object from your state view dynamically during rendering, then place it in the --resolve-variables flag.

As I mentioned in the tutorial, as a general rule if you have a screen which modifies data and has an explicit "Save" action, then you will often want to declare the object as a member variable, using --member-variables. Doing so allows that object to evolve outside your global XXXState, and if the user cancels the change without saving, leaves your XXXState without any changes.

If your UI constantly saves changes the user makes, then you are more likely to refer to it using an ID, using --resolve-variables. Each time you save the object, it will be updated in your global state, and your UI will dynamically use the latest version of the object from your XXXState.

Adding More Member Variables and Resolve Variables Later

As in the state section above, you can use the generate augment command to add member or resolve variables to your route parameter later. Note that when you do, you specify the class name of the UI element, not the route parameter.

For example, the command:

dart bin/xxx_afib.dart generate augment TodoDetailsScreen --member-variables "String hashTag;" 

Will add the boilerplate for the new hashTag member variable to the TodoDetailsScreen's route parameter.

Don't Copy/Paste Screens

You might be tempted to copy/paste one screen file to create another screen file. The generate ui command modifies several files when it creates a new screen. Consequently, I recommend that you always use the generate command for new UI elements.

Configuring the UI Prototype

When you generate a new UI construct, AFib will also create a file with an _tests suffix. For example:

quick_start_screen.dart -> quick_start_screen_tests.dart

Within that file will be a single initial UI prototype. For example:

The Quick Start Screen Prototype
void _defineQuickStartScreenPrototypeInitial(AFUIPrototypeDefinitionContext context) {  
  var prototype = context.defineScreenPrototype(
    id: XXXPrototypeID.quickStartScreenInitial,
    stateView: XXXTestDataID.xxxStateFullLogin,
    navigate: QuickStartScreen.navigatePush(),
  );
  ...
}

This prototype is accessable in the 'UI Prototypes' section of prototype mode. If you navigate to the UI prototype in prototype mode, you will be able to see the UI element update in real time each time you modify its buildWithSPI function and save its source file.

When I begin developing a new screen, the first thing I do is switch to prototype mode, navigate to its UI prototype, and then begin prototyping the actual UI, which updates in real time as I save the dart file.

Configuring State View Data

Inititally AFib links the state view to the xxxStateFullLogin test data element. This is intended to be a fully-formed sample state for your app. You can also manually create additional full states in your test_data.dart, and refer to them instead.

However, you can also specify the content of states and state views more flexibly, as described in the next several sections.

How States/StateViews work internally

The key to understanding the flexibility of your state (e.g. XXXState) and state view (e.g. XXXDefaultStateView) is understanding that root objects within them are stored in a map by type name. For example, an object with the type UserCredentialRoot is in the map under the string key "UserCredentialRoot". As a result, given any object, AFib can write it into a state or state view by updating this string key in the map. Given any runtime type, AFib can dynamically find it in the root of the state or state view.

Specifying Only the Root States Your UI Uses

Suppose your app's XXXState contains 10 different root elements, but the prototype screen in question references only three of them -- SettingsRoot, UserCredentialRoot, and UsersRoot. In that case, you can provide test data identifiers for values of those three types in an array.

...
    stateView: [
      XXXTestDataID.settingsChris, 
      XXXTestDataID.userCredentialChris, 
      XXXTestDataID.usersChris
     ],

The three values will be merged into a state view, and as long as you only reference the accessors for those root state elements within your UI, your prototype will work properly.

Specifying Objects Instead of Test IDs

Anywhere you can specify a test identifer, you can also specify an object instead. For example, the code above might be rewritten with the UserCredentialRoot explicitly declared, rather than pulled from the test data.

    stateView: [
        XXXTestDataID.settingsChris, 
        UserCredentialRoot(id: XXXTestDataID.userCredentialChris, email: "chris@nowhere.com"), 
        XXXTestDataID.usersChris,
    ],

Test data tends to be highly reusable, so I usually place test data in the test_data.dart file, and reference it using test ids.

Overriding Specific Root Objects

If you specify an array of objects, then they are consolidated into a state view in order.
So, for example, if the first test id references an instance of XXXState with 3 todo list items in its TodoItemsRoot value, and the second an instance of TodoItemsRoot with 10 todo list items, then the large list will override the smaller list, leaving everything else about XXXState the same:

    stateView: [
        // We start with our full sample state, which includes the a TodoItemsRoot with 3 items
        XXXTestDataID.xxxStateFullLogin,
        // But then we also specify a TodoItemsRoot with 10 items, that object will replace the
        // original TodoItemsRoot, but leave all the other root objects from the original state
        // unchanged.
        XXXTestDataID.todoItemsLongList 
    ],

This ability to override just a few of many root elements in your state view makes it easier to create differentiated test cases without recreating entire states.

Note that you can perform this same sort of override directly in the test data itself, if you wish to reuse the result in several locations:

test_data.dart
  // this does the same resolution logic that was shown above in the UI prototype, but does it in the 
  // test data itself.
  final listOfRootModels = context.defineRootStateObjectList(XXXTestDataID.listFullLoginWithMoreTodoItems, [
        XXXTestDataID.evexStateFullLogin,
        // But then we also specify a TodoItemsRoot with 10 items, that object will replace the
        // original TodoItemsRoot, but leave all the other root objects from the original state
        // unchanged.
        XXXTestDataID.todoItemsLongList 
  ]);

  // here, we are defining an XXXState containing the resolved root objects.
  context.define(XXXTestDataID.stateFullLoginWithMoreTodoItems, XXXState.fromList(listOfRootModels));

Having done so, we can refer to our new state with more todo items in several places, like this:

    ...
    stateView: XXXTestDataID.stateFullLoginWithMoreTodoItems,
    ...

Configuring the Route Parameter

The prototype definition also takes a navigate parameter, which you will populate using the screen's static navigatePush method. That method returns an initial route parameter.

As you begin to build non-trivial screens, your navigatePush method will begin to take function parameters. It will use those parameters to establish the initial state of the screen's route parameter. For example:

    AFNavigatePushAction navigatePush({
        required TodoItem itemToEdit,
    }) {
        return AFNavigatePushAction(
            launchParam: EditTodoItemScreenRouteParam(
                itemToEdit: itemToEdit,
            )
        );
    }

In the prototype definition, you can hard-code these values, or pull them from the test data using identifiers using context.find, like this:

void _defineEditTodoItemPrototypeInitial(AFUIPrototypeDefinitionContext context) {  
  var prototype = context.defineScreenPrototype(
    id: TDLEPrototypeID.editTodoItemInitial,
    stateView: TDLETestDataID.xxxStateFullLogin,
    navigate: EditTodoItemScreen.navigatePush(
        // You may want to lookup test data in order to pass it into parameters
        // of navigate push.
        itemToEdit: context.find<TodoItem>(TODOTestDataID.todoRememberTheMilk)
    ),
  );
  ...
}

Building AFib UI

Now that you have a UI element, your goal is to implement its buildWithSPI method:

Widget buildWithSPI(YourUIElementSPI spi) {
    ...
}

The SPI contains a context member variable which provides access to the route parameter and state view for your UI element.

Break Complex UI Down into Local Subprocedures

When I first started using Flutter several years ago, it was common to find online code examples of static constant UI declarations, like this:

Widget build(BuildContext context) {
    return Padding(
        padding: const EdgeInsets.all(10),
        child: ListView(
          children: <Widget>[
            Container(
                alignment: Alignment.center,
                padding: const EdgeInsets.all(10),
                child: const Text(
                  'Your Title',
                  style: TextStyle(
                      color: Colors.blue,
                      fontWeight: FontWeight.w500,
                      fontSize: 30),
                )),
            Container(
                alignment: Alignment.center,
                padding: const EdgeInsets.all(10),
                child: const Text(
                  'Sign in',
                  style: TextStyle(fontSize: 20),
                )),
            Container(
              padding: const EdgeInsets.all(10),
              child: TextField(
                controller: nameController,
                decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: 'User Name',
                ),
              ),
            ),
            Container(
              padding: const EdgeInsets.fromLTRB(10, 10, 10, 0),
              child: TextField(
                obscureText: true,
                controller: passwordController,
                decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: 'Password',
                ),
              ),
            ),
            TextButton(
              onPressed: () {
                //forgot password screen
              },
              child: const Text('Forgot Password',),
            ),
            Container(
                height: 50,
                padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
                child: ElevatedButton(
                  child: const Text('Login'),
                  onPressed: () {
                    print(nameController.text);
                    print(passwordController.text);
                  },
                )
            ),
            Row(
              children: <Widget>[
                const Text('Does not have account?'),
                TextButton(
                  child: const Text(
                    'Sign in',
                    style: TextStyle(fontSize: 20),
                  ),
                  onPressed: () {
                    //signup screen
                  },
                )
              ],
              mainAxisAlignment: MainAxisAlignment.center,
            ),
          ],
        ));
    }

I do not prefer this style, as I find it is often duplicative and difficult to understand and maintain.

Instead, I recommend breaking your UI build into subprocedures, each one taking your SPI as a parameter, similar to this:

Widget buildWithSPI(LoginScreenSPI spi) {
    final t = spi.theme; // the theme, explained below.

    final rows = t.column(); // just returns a List<Widget>.
    rows.add(_buildTitle(spi));
    rows.add(_buildEmailSection(spi));
    rows.add(_buildPasswordSection(spi));
    rows.add(_buildSigninSection(spi));
    return ListView(children: rows);
}

When dealing with a list of items, you will often pass the item itself to a subprocedure:

    final t = spi.theme; 
    final rows = t.column(); 
    for(final todoItem in spi.todoItems) {
        rows.add(_buildTodoItem(spi, todoItem));
    }
    return ListView(children: rows);
}

Push data references into the SPI

Although you could access the data in the route parameter and state view directly from the UI code, like this:

Widget buildWithSPI(TodoListScreenSPI spi) {
    // here, we are reaching down into the SPI's context member to access the route parameter 
    // and state view directly from the UI code, this is undesireable.
    final context = spi.context;
    final routeParam = context.p;
    final stateView = context.s;

    // this is the kind of code that belongs in your SPI, not in your UI build function.
    // it is business logic which combines information from your route parameter and state view.
    final todosShowing = stateView.todoItems.findWhere((todo) => todo.satisfiesFilter(routeParam.filter));

    // Do this instead, and place the filtering logic above in the todosShowing 
    // accessor method (see below).  
    // Note that we now have no need to access the routeParam or stateView
    // directly from the UI rendering code, the SPI does it for us.
    final correct = spi.todosShowing;
}
Doing so is undesirable, as business logic that leaks into your UI code will not be easily accessible from your state tests later.

Instead, I recommend that your SPI expose a simplified API that your UI uses for rendering, as described below.

Use Accessors for Simple Data

You can use Dart accessors, which look just like member variables to the caller, to expose data from the SPI to your UI. For example, the above code could be re-written in the SPI as:

class TodoListScreenSPI extends ... {
    ...

    Iterable<TodoItem> get todosShowing {
      // this can be re-written in a single line using List.where, but I wanted to make
      // this code more approachable.
      final filter = context.p.filter;
      return context.s.todoItems.findWhere((todo) => todo.satisfiesFilter(filter));
    }
}

Even direct access to simple member variables in the route param is best encapsulated in an SPI accessor, for example:

...
    int get todoFilter => context.p.filter;

As you will avoid the verbose syntax:

    // this is awkward
    e.expect(spi.context.p.filter, ft.equals(...));

    // this is better
    e.expect(spi.todoFilter, ft.equals(...));

In your tests, and use spi.todoFilter instead.

Use Member Variables for Complex Data

Dart and Flutter are extremely fast, and I have never seen repeated calls to accessor methods yield a performance problem. However, when deriving a more complex data structure, I often like to make it a final member variable in the SPI, if only because doing so provides a single clear place to debug the business logic associated with generating the value.

You can do so by adding a member variable to your SPI and populating it in the create factory function for your SPI, like this:

@immutable
class TodoListScreenSPI extends TDLEScreenSPI<TDLEDefaultStateView, TodoListScreenRouteParam> {
  // add a member variable for your derived data.
  final List<TodoItem> todosShowing;

  TodoListScreenSPI(AFBuildContext<TDLEDefaultStateView, TodoListScreenRouteParam> context, AFStandardSPIData standard, {
    // populate it from the constructor
    required this.todosShowing,
  }) : super(context, standard);

  factory TodoListScreenSPI.create(AFBuildContext<TDLEDefaultStateView, TodoListScreenRouteParam> context, AFStandardSPIData standard) {

    // generate the value in the factory create method.  
    // Note that you have access to the context as a
    // parameter to this function.
    final result = stateView.todoItems.findWhere((todo) => todo.satisfiesFilter(routeParam.filter)).toList();

    return TodoListScreenSPI(
      context,
      standard,
      // now, we pass our generated result to the member variable
      todosShowing: result,
    );
  }

Note that the syntax for the UI is the same whether your data is an accessor method or a member variable: spi.todosShowing in this case.

Use the Functional Theme to Centralize UI Conventions

Use Typeahead to Discover Theme Methods

All SPIs have a theme member variable. During prototyping, the theme allows you to avoid hard-coded values in favor of conceptual values that can be configured from a central location later. For example:

    final t = spi.theme; // or spi.t for brevity.
    return Container(
        // a standard margin discoverable via typeahead by starting with the parameter name.
        // see how t.margin... starts the same way as the "margin:" parameter?
        margin: t.margin.standard,
        // instead of 
        margin: EdgeInsets.all(8.0),
        ...
    );

Themes are covered in much more detail in a subsequent section, but for now know that they are designed to be accessed via typeahead matching the named parameter you are trying to fill in. For example:

    final t = spi.t; 
    return Container(
        decoration: BoxDecoration(
            color: t.colorPrimary, // note that 'color:' matches 't.color...'
            borderRadius: t.borderRadius.standard, // and 'borderRadius:' matches 't.borderRadius...'
        )
        ...
    );

So, I recommend you try using typeahead for values that seem like things you might like to theme, you will often find something useful.

Place Simple Common UI in the Default Theme

I find that there is a large class of UI elements which are shared across many screens in my app, but which feel too small to warrant there own distinct widget classes. Icons are a good example. Your default theme is a good place to put this functionality. Technically, your app can have multiple themes as it grows larger.

When the theme does not already contain a typeahead value for the commonality you wish to express, you can add a method or accessor to it. You will find it in

lib/ui/themes/xxx_default_theme.dart

For example, you might add something like:

class XXXDefaultTheme extends AFFunctionalTheme {
    ...
    // note that I am preserving the typeahead convention here,
    // because this will fill in a 'decoration:' parameter.
    Decoration get decorationCongratsTitle {
        return BoxDecoration(
            color: Colors.green,
            borderRadius: borderRadius.top.standard,
        );
    }

}    

I find having a convenient place to put common UI useful in prototyping, as it helps avoid duplicated code at a time when the code is likely to be changing quickly, and you'd like to defer decisions about UI conventions (e.g. what icon should we use for editing an item) in a maintainable way. It also makes discovering emerging UI conventions easier (via typahead) if multiple contributors are developing the prototype.

Use the theme's child... methods for common widgets

Note that because many widgets have child widgets, there are many functional theme variants under child.... For example:

    final t = spi.t;
    return Container(
      color: t.colorPrimary,
      // again, see how t.child... matches the "child:" parameter name?
      child: t.childText("Hello World")
    );

There are a variety of advantages to using these theme-based methods over using Flutter widgets directly (e.g. Text("Hello World");). I recommend you prefer using a theme method if one exists.

Create rows and columns

Although it is used in many examples above, I like the syntax:

final rows = t.column();
final cols = t.row();
Both of these methods do nothing more than return a new List<Widget>.

Casting from One Theme To Another

Because functional themes are not allowed to have their own data, you can always get one theme from another. For example, if you have your own applications default theme, and wish to access the theme for the AFib Signin library (which you must have integrated, as described below), you could use:

  final afsiTheme = t.accessTheme<AFSIDefaultTheme>(AFSIThemeID.defaultTheme);

Themes and Internationalization

You can use t.translate(...) to translate a string in your prototype language, an XXXTranslationID, or an XXXWidgetID into a localized String. The translations themeselves are specified within xxx_define_core.dart as part of the fundamental theme.

Internationalization is covered in the production section.

Themes and Flutter Keys

Native Flutter widgets are identified in the widget hierarchy using the Key class. You can convert from AFib Widget Ids to Keys using t.keyForWID(XXXWidgetID.buttonWhatever).

Note that t.keyForWID allows the widget ID to be null, in which case it returns a null Key. Some Flutter widgets require a non-null key, in which case you can use t.keyForWIDNotNull.

Also note that most theme.child methods take a widget ID directly, so you do not need to convert to a key in those cases.

Implementing SPI Event Handlers

Up to this point, this section has been focused on using the route parameter, state view and theme to construct a set of widgets which display on the screen. Now, we will focus on handling events that occur when the user interacts with your UI. For example:

    final t = spi.t;
    return FlatButton(
        child: t.childText("Increment Counter"),
        onPressed: spi.onPressedIncrementCounter,
    );

Here, our UI will display a button on the screen, and we want to handle the button press. Our SPI exposes a method that handles the event:

class HomeScreen extends... {
    ...
    void onPressedIncrementCounter() {
        // remember, the route parameter is immutable, we have to copy (revise)
        // it to alter it.
        final revised = context.p.reviseIncrement();

        // This will cause a re-render, because our route parameter has changed.
        // Within the SPI, think of the context as exposing an API that allows you to 
        // manipulate AFib's state in a variety of ways.
        context.updateRouteParam(revised);
    }
}

SPI Event Handlers Will Be Accessible From Testing

Many of our tests will subsequently be written at the SPI level. Moving event handling code into the SPI makes that code easily accessible from those tests later. If we had written the code above this way instead:

    final t = spi.t;
    return FlatButton(
        child: t.childText("Increment Counter"),
        onPressed: () {
            // this will work just fine, but this code won't be accessible through our fastest
            // and most reliable form of testing -- the state test -- because that test builds
            // only the SPI, it doesn't actually render the UI.
            final context = spi.context;
            final revised = context.p.reviseIncrement();
            context.updateRouteParam(revised);
        }
    );

It would have produced the same result, but would have been more difficult to test.

Manipulate AFib's state within the SPI using its context

The SPI's context member variable provides a variety of methods for manipulating AFib's state. Typically, your event handlers will invoke one or more of these methods.

The typeahead prefixes you will most often use are:

context.update...
Update a route parameter, or occasionally update the global component state, but that is more often done via a query.
context.navigate...
Navigate to a new screen, updating AFib's route state
context.show...
Show a dialog, bottom sheet, or drawer, updating AFib's route state.
context.execute...
Execute a query, which will usually in turn update the global componnent state.

Use Typeahead to Name/Access SPI Event Handlers

I recommend you use the same typeahead convention for naming SPI event handlers that you do with themes. Start them with the name of the parameter they are supplied to:

    onPressed: spi.onPressed...
    ...
    onTap: spi.onTap...
    ...
    onUpdate: spi.onUpdate...

Maintaining Widget IDs

Creating Widget IDs (and other Ids) in VSCode

AFib provides a simple VSCode extension which makes maintaining the (perhaps too copious) IDs used in AFib easier. It is particularly useful for XXXWidgetID and XXXTestDataID values, which you will create frequently. To use it, type the full name of your desired widget id directly into your code:

    rows.add(t.childText(
        "Hello World",
        wid: XXXWidgetID.textTitle,
    ));

Your new widget id will initially not exist, yielding a compiler error. Then hit Command-Shift-P, and select "Fixup AFib Identifier(s)" AFib will add a constant identifier to your XXXWidgetID class, resolving the compiler error.

AFib Widget IDs are declared like this:

  static const textTitle = XXXWidgetID("textTitle");

While Test Data IDs are declared as simple strings, like this:

  static const userChris = "userChris"

If you refactor an ID in your code, the symbol name will update, but the string value will remain unchanged, causing the two to be out-of-sync. Leaving them out-of-sync is confusing for testing and debugging purposes.

You can fix that issue by opening your lib/xxx_id.dart file, and running the "Fixup AFib Identifier(s)" command within it. It will synchronize all your ids, reporting the results in the Output panel.

Differentiating Widget IDs

In lists of objects, you will often need to append an object identifier to a widget ID to produce a unique value. You can do so using the with1, with2 and with3 methods. For example, in this UI generation code:

    for(todoItem in spi.todoItems) {
        rows.add(TodoItemWidget(
            wid: TDLEWidgetID.widgetTodo.with1(todoItem.id),
             // if the widget does not modify the todo item, its best to pass in only the 
             // id, so that it can late bind to the actual todo item by accessing it from the
             // stateView.
            todoId: todoItem.id,
        ));
    }
The actual widget id will be suffixed with _${todoItem.id}. If the actual todo items in the list were test data with the IDs XXXTestDataID.rememberTheMilk and XXXTestDataID.mowTheLawn, then the resulting widget ids/Flutter Keys would be widgetTodo_rememberTheMilk and widgetTodo_mowTheLawn, which can be useful in debugging. In production code, it might be something like widgetTodo_43526 if you database uses integer ids.

Access the Full Current State in an Event Handler

Your UI generation code should always render itself using data from the route parameter and state view found in the spi.context, as your UI re-renders when either of those values change. If you render your UI using data from outside those two values, the UI will not re-render automatically.

In most cases, the state necessary to implement an event handler will also be found in the route parameter and state view.

However, in rare cases (especially when overriding an event handler for a third party component), you may need access to the entire AFib state. You can access the entire AFib public state in an event handler using:

    final eventContext = context.accessOnEventContext();
    final xxxState = eventContext.accessComponentState<XXXState>();    
    /// now you can access any root state object, you can also access state
    /// of third-party afib libraries.

Note that if you have a navigatePush method which needs access to a signficant subset of your root state, you can simplify your code by creating a navigate push function that takes the AFOnEventContext:

  context.navigatePush(ComplicatedScreen.navigatePush(
    eventContext: context.accessOnEventContext(), // inside navigatePush, you can access whatever you want.
    ...
  ));

However, when you do this, you will need access to an AFOnEventContext in your UI prototype's declaration, you can access on using the navigateWithEventContext parameter:

  var prototype = context.defineScreenPrototype(
    id: EVEXPrototypeID.complicatedScreenInitial,
    stateView: EVEXTestDataID.evexStateFullLogin,
    navigateWithEventContext: (eventContext) {
        return ComplicatedScreen.navigatePush(
          eventContext: eventContext,
          ...
        );
    },
  );

In the UI prototype context, the eventContext.accessComponentState calls will work, but they will only have access to the root objects defined in your stateView parameter.

Working With Flutter State in AFib

Several major Flutter Widgets -- for example the text editing control, scrolling controls, focus controls, and various gesture recognizers - are stateful. A screen which contains these widgets needs to preserve these widgets' states for the duration of the screen's existence.

AFib route parameters already last for the duration of the screen. Consequently, the route parameter is the correct location to host the state for stateful flutter widgets.

AFib itself provides a utlity class, AFFlutterRouteParamState, which makes it easy to manage references to the major Flutter widget state types. It also provides a route parameter parent class -- AFRouteParamWithFlutterState -- which has a member variable of that type, and which automatically calls dispose on any Flutter widget state you create.

Specifying Flutter State in generate ui

All the

dart bin/xxx_afib.dart generate ui ...

variants support a --with-flutter-state flag. If you use the flag when generating UI, much of the code below will be included for you. It is still worth reading the subsequent sections so that you understand why the generated code works as it does.

Preserve Flutter State in copyWith

The Flutter state should exist as long as the screen does, so there is no need to copy or modify it in your copy with method. Instead, you will just pass the existing Flutter state through:

    void copyWith({
        User? user,
        // note that there is no need to have a parameter for the flutter state.
    }) {

        return EditUserScreenRouteParam(
            // the normal thing, modify the user iff it was supplied.
            user: user ?? this.user,

            // but the flutter state gets preserved for the life of the route parameter:
            // (the Guaronteed suffix is necessary to assure Dart that the flutterState is non-null)
            flutterState: this.flutterStateGuaranteed,
        )
    }

Editing Simple State, like Focus Controllers

In most cases, to manage Flutter state in a route param, you declare an AF...Holder class, where the ... is the name of the type of state, and pass it to the AFFlutterRouteParamState, which is in turn part of the route parameter:

    final focusNodes = AFFocusNodesHolder();

    final flutterState = AFFlutterRouteParamState(
      focusNodes: focusNodes
    );

Then, in your UI code, you access the desired focus node using the widget id for your widget:

    final focusNode = spi.context.p.accessFocusNode(TDLEWigetID.focusEditName);

The AF...Holder class hides the messy details, and insures that any flutter state'sdispose method gets called when the route parameter is disposed.

Special Provisions for Managing Text Edit State

I repeatedly tried to simplify the management of Flutter state for Flutter's TextEdit field, with limited success. Although it is not a meaningful issue for developer productivity, the complexity associated with managing TextEdit fields is one of my biggest frustrations with AFib.

The follow sections describe the steps necessary to properly use a TextEdit field in AFib.

Initialize The State In Your Route Parameter

Your route parameter should derive from AFRouteParamWithFlutterState, like this:

class EditUserScreenRouteParam extends AFScreenRouteParamWithFlutterState {
  final User user;
  StartupScreenRouteParam({
    required this.user,
    required AFFlutterRouteParamState flutterState, 
  }) : super(screenId: TDLEScreenID.startup, flutterState: flutterState);
...

Note that the AFFlutterRouteParamState gets passed to the parent class, which manages it. Then, within navigate push, you will need to initialize the AFFlutterRouteParamState:

  static AFNavigatePushAction navigatePush({
    required User user,
  }) { 
    // note that TDLEWidgetID.editName will also be used when we declare the widget,
    // and that the initial value in the text edit should be user.name, since we initialized
    // the controller that way.  There is a createN variant if you have many text edits.
    final textControllers = AFTextEditingControllers.createOne(TDLEWidgetID.editName, user.name);

    // the flutterState can have more than just textControllers (e.g. focusNodes, etc)
    final flutterState = AFFlutterRouteParamState(
      textControllers: textControllers,
    );

    return AFNavigatePushAction(
      launchParam: StartupScreenRouteParam.create(
        user: user,
        // note that the flutterState is part of the route parameters, and will live for as 
        // long as it does.
        flutterState: flutterState,
      ),
    );
    ...

Note that we have created an AFTextEditingControllers object which associates the TDLEWidgetID.editName widget id with the user's name value. AFTextEditingControllers has a createN method if you have multiple text editing fields.

Use the Theme Utility to Create the Text Field

AFib provides a utility function in your default theme to create a TextField:

    final t = spi.t;
    final rows = t.column();

    rows.add(t.childTextField(
      // screenId is a member variable of the screen itself.
      screenId: screenId,

      // note that this matches the widget id you used in the route parameter declaration
      wid: TDLEWidgetID.editName,

      // allows the utility to access the flutterState
      parentParam: spi.context.p,

      // handles editing, shown below.      
      onChanged: spi.onChangedName,

      // this is not required, but I highly recommend it, as it allows an assertion to catch
      // many bugs where the state in the widget itself gets out of sync with the state in your
      // route parameter.
      expectedText: spi.user.name,
    ));

All this complexity is really frustrating to me!

Regardless, the text field will use the TextEditingController which was declared for TDLEWidgetID.editName as part of the route parameter. I strongly recommend you set the optional expectedText parameter to the model value that correlates to the text field. AFib will assert false if the two get out of sync.

Synchronize the TextEdit in the SPI

In one final frustrating piece of complexity, you must call context.updateTextField as shown below when the text field changes.

    void onChangedName(String name) {
      // here is the final frustrating complexity: In the vast majority of cases this does
      // nothing, but if you omit it you will get assertion failures in state tests in UI mode.
      context.updateTextField(TDLEWigetID.editName, name);

      // this part is expected
      final revised = context.p.reviseUserName(name);
      context.updateRouteParam(revised);
    }

Doing so is necessary because in state tests run from the UI, you may manipulate the value in the route parameter repeatedly without building the UI. Because the UI is never built/manipulated, the value in the TextEditingController is never set (as it would be in the normal course of events if the UI was built).

When the UI for the final state of the state test does get built, the value within the TextEditingController will be empty, rather than being in sync with your model value, and an assertion will fail reporting it is out-of-sync.

So, the context.updateTextField call does nothing in production code, but in state tests in synchronizes the TextEditingController with the model value, so that when the UI does get built, the two match.

Working with AFib Widgets

AFib widgets are different from screens, dialogs, drawers and bottom sheets for several reasons:

  • You construct the widgets inline in your UI rendering code, rather than allowing AFib itself to construct them for you. As a result, you can pass parameters directly to the constructor.

  • Each widget has its own route parameter, which is often stored in a special set of child parameters for an existing screen. As a result, a widget's route parameter must usually be addressed using both the id of the screen it appears on and its own unique widget id within that screen.

  • Because Widgets have their own state, and because you pass them a 'launch parameter' explicitly, the behavior of the launch parameter is potentially more confusing than when you launch a screen with a route parameter.

Due to these complexities, I recommend you get comfortable will building screens, dialogs, etc before you begin building widgets.

Why Should I use AFib Widgets?

AFib widgets are useful for two (and in rare cases maybe a third) reasons:

  • They facilitate code re-use and UI consistency across screens, while maintaining all the AFib conventions you already know (the SPI, the route parameter and state view, etc).

  • They make it practical to extend third-party libraries in ways specific to your app. I will cover this in more detail in the 'third party' section, but if you ever find yourself wishing to extend a third partly library, but need access to your own app-specific route parameter and state view data, AFib widgets provide a solution.

  • Finally, you can use AFib widgets to localize updates to a single portion of a screen, rather than re-rendering the entire screen. However, this is almost never necessary, and should not be done unless you have a known performance problem (I have yet to encounter a case where this is necessary).

If You Want to Share Code Between Screen SPIs, Consider a Child Widget

If you find yourself wanting to share code between SPIs, the solution is often a child widget. A child widget shares both the UI and the SPI methods it accesses and manipulates across multiple screens. If you still want to share code across SPIs, you can often do so using mixins.

Passing Data to AFib Widgets: Three Cases

You can pass data to an AFib widget via direct constructor parameters, or via data within the launch parameter. In order to understand how data should be passed, you need to understand that in the most productive form of testing (state testing), your UI code will never be executed. As a result, anything you place in a direct constructor parameter will be invisible to your state tests.

There is a correct way to pass information for each of these three scenarios:

  • Direct UI Parameter: Pure UI Parameters - This is the easiest case. If you are passing data that is purely related to the display of the widget -- the margin, the background color -- you can pass it as a direct constructor parameter for a final member variable of the widget. You do not need to test these values in state tests anyway.

  • Direct UI Parameter: Event Handler Methods - An AFib widget will still want to delegate event handlers to its parent screen in some cases. Event Hander methods should also be passed as direct constructor parameters. This may seem counterintuitive, as the event handler will clearly be needed in the state test, but it will usually be available via the SPI of the parent screen in the state test.

  • Route Parameter: Data the Widget Will Own and Mutate Itself - Suppose your TodoItemWidget has an expanded or contracted state, which will be controlled via a boolean value in its route parameter. The widget itself owns this data, and has an icon that switches between the states. Data that is controlled and modified by the widget itself like this belongs in the route parameter for the widget.

Mechanisms for Creating Child Parameters

Each screen has a single pool of child parameters. The pool is indexed by the parameter's widget id. Consequently, all AFib-aware widgets on a page must have a unique widget id.

These parameters can be created in several ways.

Reference an Existing Parameter

In some cases, an AFib widget may reference an existing route parameter rather than having its own entry in the child parameter pool. Two common examples include:

  • Reference the parent screen parameter - Sometimes, a widget simply wants to 'pull down' the route parameter of its parent screen. This can happen when a widget comprises the majority of the screen, but is also re-used in other contexts. You can achieve this using an AFRouteParamRef, and the special flag value AFUIWidgetID.useScreenParam:
    rows.add(MyChildWidget(
        launchParam: AFRouteParamRef(
            // screenId is a member variable of the screen.
            screenId: screenId,

            wid: AFUIWidgetID.useScreenParam,

            // this is typically where a screen parameter would be.
            routeLocation: AFRouteLocation.screenHierarchy,
        ),

        // note that the widget id is not unique above.  To specify a unique widget id
        // (e.g. as the key in a list), you can use widOverride.
        widOverride: TDLEWidgetID.widgetMyChild,
    ));
  • Reference a global route parameter - You may want a widget to retain its route parameter across screens. You can achieve that using AFRouteParamRef, and a global route parameter you have previously established (perhaps in your startup query):
    rows.add(GlobalCounterWidget(
        launchParam: AFRouteParamRef(
            // You could just reference a screen-level global route parameter, or you could add
            // a child widget.
            screenId: TDLEScreenID.screenGlobalCounter,

            wid: TDLEWidgetID.widgetGlobalCounter,

            // This is the important part, it is stored in the global pool.
            routeLocation: AFRouteLocation.globalPool,
        ),
    ));

Reference a Child Route Parameter

Child route parameters are those that exist in a screen's child route parameter pool. They exist only as long as their parent screen does.

You can create child route parameters as part of the screen's navigatePush method, or inline in the UI code itself by specifying them directly as the widget's launchParam. I strongly prefer the former, as it is more consistent with the state driving the UI (rather than the UI driving the state). However, sometimes creating child route parameters in the navigatePush method is tedious, and specifing them as the widget's launchParam is justified.

  • Specifying Child Route Parameters in Navigate Push

The AFNavigatePushAction action for a screen takes a children parameter, which is an array of route parameters. Any parameters in the children parameter will be placed in the new screen's child parameter pool.

For example, you might create a child route param for each TodoItem on a page:

class TodoItemListScreen extends ... {
    ...
    static AFNavigatePushAction navigatePush({
        required TodoItemsRoot items
    }) {
        final todoItemParams = <AFRouteParam>[];
        for(final item in items.findAll) {
            todoItemParams.add(TodoItemWidgetRouteParam(
                screenId: TDLEScreenID.todoItemList,
                // make the widget id unique.
                wid: TDLEWidgetID.todoItem.with1(item.id),
                routeLocation: AFRouteLocation.screenHierarchy,

                // you might pass this in, although unless it is being edited by the widget
                // I would prefer to pass in only its ID, and look it up in the state view.
                // The later option will cause it to update dynamically if a push update for
                // it comes through, while specifying it directly into the route parameter will
                // prevent this automatic update (probably desirable if it is being edited,
                // but noth otherwise)
                item: item,

                // this is data the widget itself will own and modify.
                showingDetails: false,
            ))
        }

        return AFNavigatePushAction(
            launchParam: TodoItemListScreenParam(),

            // all the todo item params will be in the child param pool.
            children: todoItemParams
        );
    }

I greatly prefer this method. All the child route parameters are established at the time of navigation. You can see them in the debugger immediately after the navigation occurs.

If you use this method, your widget launch parameter will simply refer to the child parameter that already exists:

    for(final item in spi.todoItems) {
        rows.add(TodoItemWidget(
            launchParam: AFRouteParamRef(
                screenId: screenId,

                // note that this wid matches the wid on the route parameter that was created during
                // navigation.
                wid: TDLEWidgetID.todoItem.with1(item.id),

                routeLocation: AFRouteLocation.screenHierarchy,
            )
        ))

    }
  • Generating the Child Route Parameter During the UI Build

I really dislike generating state within the UI code, but it is a workable option and is sometimes more convenient than pre-creating all the child route parameters. This is especially true when you are introducing your own widgets into a third party UI by overriding the third party UI's theme. To do this, you would merge the two code examples above, and simply build an actual launch param directly in the UI code:

    for(final item in spi.todoItems) {
        rows.add(TodoItemWidget(
            // the launch param is specified directly in the UI code, rather than 
            // creating it in navigatePush, and referring to it with an AFRouteParamRef
            // in the launch param.
            launchParam: TodoItemWidgetRouteParam(
                screenId: TDLEScreenID.todoItemList,
                wid: TDLEWidgetID.todoItem.with1(item.id),
                routeLocation: AFRouteLocation.screenHierarchy,
                item: item,
                showingDetails: false,
            )
        ))
    }

The Launch Param is Only Certain to Work on First Launch!

Specifying an actual route parameter directly in the UI code can be confusing. Suppose our TodoItemWidgetRouteParam above had a showDetails parameter, and the parent screen has an icon which shows the details of all todo items. You might end up with something like this.

    for(final item in spi.todoItems) {
        rows.add(TodoItemWidget(
            launchParam: TodoItemWidgetRouteParam(
                screenId: TDLEScreenID.todoItemList,
                // make the widget id unique.
                wid: TDLEWidgetID.todoItem.with1(item.id),
                routeLocation: AFRouteLocation.screenHierarchy,

                // you might pass this in, although unless it is being edited by the widget
                // you could just look it up in the state view using the item.id.
                item: item,

                // This looks good, and it will initially work, but as soon as the widget modifies
                // its own route parameter, this launchParam value will no longer be used, and changes
                // passed in here will no longer be relevant.
                showDetails: spi.shouldShowDetailsForAll,
            )
        ))
    }

The problem is that once the child widget itself calls context.updateRouteParameter, it creates a copy of the route parameter in the state that supersedes the launchParam value specified during the UI build.

As a result, the 'showDetails' value specified in the launchParam will be respected, up until the child widget changes its own route parameter, at which point it will be ignored.

You should try to think of the launch param as an initial value that is subsequently owned by the child widget. You should not imagine that you can pass ongoing updates to the widget via the launchParam value during rendering.

Always create the launch param via an SPI Method

I will explain this point more deeply in the state testing section, but for now, the launch parameter used in your UI code should always be created within your SPI. I did not do this in the examples above for simplicity, but the Todo example above should be written:

    for(final item in spi.todoItems) {
        rows.add(TodoItemWidget(
            // note that I preserve the typeahead convention here.
            launchParam: spi.launchParamForItem(item);
        ))
    }

This is because in state tests your will need to provide the launch parameter when referencing a child widget, and you will have access to the SPI, but will not execute the UI build itself. Placing creation of the launch parameter in the SPI of the parent screen makes it easily accessible from the state test, and will subsequently allow you to build and manipulate the SPI of the child widget in your test.

Screen Lifecycle Methods

All screens and widgets have some calls that you can override. I very rarely find that I need to do this, but the methods are:

void onInit(Store store)

This method is called when your widget is first displayed.

void onDispose(Store store)

This method is called when your widget is going out of scope. Be sure to call super.onDispose.

void onInitialBuild(AFBuildContext context)

This method is called after the widget is built for the first time.

void onWillChange(AFBuildContext previous, AFBuildContext next)

This method is called before the widget is re-rendered in response to a state change.

void onDidChange(AFBuildContext previous, AFBuildContext next)

This method is called after the widget is re-rendered in response to a state change.