Prototype State
AFib enables you to prototype client-side state at the same time you are prototyping your UI.
Generate state before generating code that depends on it
If you generate your state before you generate code that depends on it, the imports will be added for you.
When AFib generates code that refers to a model or root state object, it looks to see if a file for the class exists in the expected location on the filesystem. If it does, it will automatically import it. If not, the new code will have a compiler error until you manually import the correct file (which you can easily do with a quick-fix in VSCode).
Prototyping state with your UI is Useful
Although in theory you could build prototypes by hard-coding state directly into your UI code, like this:
Widget buildWithSPI(TodoListScreenSPI) {
final t = spi.theme;
final rows = t.column();
rows.add(_buildTodoItem("Remember the milk"));
rows.add(_buildTodoItem("Mow the grass"));
...
}
I find hard-coding prototype data into UI prototypes tedious and inflexible. AFib is designed to allow you to feed test data into your UI prototypes in a flexible way. In doing so, you have the opportunity to prototype client-side data structures at the same time you build prototype UIs.
I find thinking about client-side data structures during prototyping helps me build better UI prototypes.
Designing easy-to-maintain state
This section contains guidelines which are not requirements for using AFib. It explains how to generate immutable state that is convenient to maintain. The AFib generate
command in the following section makes it easy to generate state that conforms to these recommendations.
Defer 'linking/joining' of objects as long as possible
Because all AFib state is immutable, you cannot change an existing object by altering its member variables. Instead, you will always create a copy of the object with a new and different (revised) internal state. Given this requirement, it is inconvenient to maintain deeply nested data structures.
For example, suppose you create a TodoItem
like this:
@immutable
class TodoItem {
final String id;
final String title;
// The User object referenced below will prove inconvenient,
// because anytime a user changes, you have
// to search through all the todo items and revise any one that refers to
// that user. Remember, you can't update the user that is 'pointed to' here
// by mutating it. Instead, you make a copy, but this TodoItem will continue
// to refer to the prior pointer/version unless you explicitly revise it as well
final User assignedTo;
...
}
This design will prove inconvenient, because anytime you update a User
, you make a copy of it, and as a result to propogate the update you will have to search through all the TodoItem
objects and update each one that refers to that user.
A far more convenient design will be:
@immutable TodoItem {
final String id;
final String title;
final String assignedToId;
// we will call this from our SPI during UI rendering. As a result, our
// 'linking' of todos to users happens as late as possible.
User? resolveAssignedTo(UsersRoot users) => users.findById(assignedToId);
}
In this design, the TodoItem does not need to be revised when the assignedTo user changes. We can use the resolveAssignedTo
method to look up the assigned to user as we are rendering the UI, ensuring that our UI always shows the latest version of the user.
class TodoListScreenSPI ... {
...
// Here, we are dynamically resolving the user the item is assigned to as the
// UI is being rendered. As a result, if the user changes due to a push notification
// we will always use the freshest copy.
User assignedTo(TodoItem todoItem) => todoItem.resolveAssignedTo(context.s.users);
}
context.s.users
above), and we look it up in real-time as the UI renders.
Think of State in Terms of Root State and Models they contain
The implication of this 'shallowness' design preference is that your state will usually have only one level of nesting.
The objects immediately below your main XXXState
are your root state, and will usually have a map from the String
ids of your objects to the objects themselves. For example, the UsersRoot
implied above might look like:
@immutable
class UsersRoot {
final Map<String, User> users;
...
User? findById(String id) {
return users[id];
}
}
In this example, User
is a model object. Model objects will generally not contain references to other objects (unless you are working with a json-y store like firebase/mongo which has embedded nested data structures, which are all stored under a single id).
Root State and Models in Relational Database Terms
If you are familiar with relational database design, then your root objects will correlate to tables, and your models will correlate to each row in the table.
Your model objects will have member variables containing foreign key values, rather than pointers to other model objects. Rather than 'joining' multiple tables together when you deserialize your objects (the example with the TodoItem
containing the User
objects directly above), you will wait to 'join' your tables until as late as possible, using the 'resolve' method shown above.
Generating State
AFib's generate state
command has options which make generating a state like the one described above easy.
How to Generate Models and Root State
You can generate state using a command like:
dart bin/xxx_afib.dart generate state TodoItem --member-variables "String id; String title;" --resolve-variables "User assignedTo" --add-standard-root
This command will create a TodoItem model in the file:
And will also create a root set of TodoItems called TodoItemsRoot in the file:
The TodoItemsRoot will automatically be added to your default state view ascontext.s.todoItems
.
If you understand the three flags supplied to this command, you have all the tools you need to conveniently generate state in AFib.
This command generates boilerplate for member and resolve variables
The --member-variables
flag creates member variables in your new model, as well as standard revise, copyWith, and simple serialization functionality.
copyWith method
When working with immutable data structures, you will frequently want to change just a few member variables in larger object. You can achieve this with the copyWith
method.
This command will generate a standard copyWith method that allows you to create a new object with any of the member variables or resolve variables modified.
Revise methods
If you want, you can sprinkle calls to copyWith throughout your code.
However, I find that relationships often develop between different member variables within an object, and that it is convenient to maintain these relationships within a single method, which I prefix with revise
. I also like to be able to see all the available revise
methods using typeahead.
By default, AFib will generate revise
methods for all the values in --member-variables
and --resolve-variables
. You can disable this behavior using --no-revise-methods.
These methods are often convenient in their own right, but they are there primarily for consistency. As your app grows more complex, you are likely to add revise...
methods which encapsulate more complex update code. Future developers can use the .revise...
typeahead to discover standard ways of revising existing models in your app.
Serialization methods
By convention, Dart uses a Map<String, dynamic>
to represent nested serial data (e.g. json, data from Firebase Firestore, etc). By default, AFib will include constants and simple seriaization code using this convention.
AFib does not have a serialization framework, and it is not very important that you use this particular form of serialization. However, some AFib-aware libraries (e.g. afib_firestore_firebase
) will produce more complete code if you have followed this convention. If you do not want the serialization code, you can use --no-serial-methods
.
resolve methods
The --resolve-variables
flag above creates a String assignedToId
member variable, as well as a resolveAssignedTo
method similar to the one described above. It also generates standard revise, copyWith, and serialization functionality.
class TodoItem {
final String id;
final String title;
final String assignedToId;
...
User resolveAssignedTo(UsersRoot users) => users.findById(creatorId);
...
}
This command generates and integrates a TodoItemsRoot
The --add-standard-root
flag generates a TodoItemsRoot
class, and automatically installs it at the root of your state and default state view. As described above, the TodoItemsRoot
is just a mapping of String ids to TodoItem objects. It comes with utilities for finding TodoItems and revising the set of items easily. The users root object will be placed in:
Client-Side Object Ids should be Strings (even if they aren't)
AFib assumes that your test data will have string-based object identifiers. Using String-based object identifiers for test data makes it easier to debug your code, as the object identifier tells you a lot about the data you are working with in the debugger, and object identifiers often end up embedded in widget ids, making the content of the widget easier to understand in the debugger.
If you are using a json-y backend like Cloud Firestore, String-based object identifiers will be natural. If you are using a relational backend, you are more likely to have integer identifiers. I recommend you convert those to-from Strings during seralization.
If I had specified an integer ID in the command above, like this:
dart bin/xxx_afib.dart generate state TodoItem --member-variables "int id; String title;" --resolve-variables "User assignedTo" --add-standard-root
AFib would automatically generate a TodoItem with a String id
, and with conversion code to and from integers in the serialization functions, allowing you to continue using String test identifiers on the client side.
Augment Existing State
The boilerplate code created by AFib -- the member variables, constructor parameters, copyWith method, revise and resolve methods, and serialization logic -- is tedious to maintain by hand.
AFib supports a generate augment
command, which can add member and resolve variables to an existing model (or route parameter, or query). For example, the following command:
dart bin/xxx_afib.dart generate augment TodoItem --member-variables "String notes;" --resolve-variables "User creator"
Would add all the boilerplate for both the member variable notes
, and the resolve variable creator
to an existing TodoItem
model.
Generate Root State Directly
It is usually more convenient to generate a root state using the `--add-standard-root
flag on the command that generates your model.
However, you can generate a root state object directly by suffixing 'root' to your class name.
Will generate the file:
You can also specify --add-standard-root
for a root state object, but when doing so you must specify the model name, like:
Note that root objects do not need to use any particular superclass. If the standard map is not appropriate in your case, you can use the --member-variables
flag instead, or no flag at all, in which case you can code member variables by hand after the generation command completes.
Additional Root State Code Generation
Generating a new root state object will produce a number of other code modifications:
- Adds an accessor method to both your XXXState and XXXDefaultStateView
-
An accessor is added to your
XXXStateModelAccess
mixin, which gives access to your new root state object from bothXXXState
andXXXDefaultStateView
. For example, the TodoItem example above would yield a method like:This method allows you to access your root state object from both queries (described below), and the state view in the SPI by using
state.todoItems
orstateView.todoItems
respectively. - Adds a static initialState method to the new classs
-
New root state classes have a static
initialState
method. The initial state of yourXXXState
is automatically populated with this value when your app first starts. AFib automatically links this initial state into your overall initial application state.If you specify complex values via the
--member-variables
flag, AFib may not guess the correct syntax for creating an initial state, yielding compiler errors that you need to fix by hand within the initialState function. - Adds an initial value in your test data, and links it into your xxxStateFullLogin state
- By default, AFib will create a
XXXTestDataID.stateFullLogin<YourClassName>
entry in your test data, also populated with the result of theinitialState
function, and link it into your overallxxxStateFullLoginState
test state. You will most likely want to enhance this test state to be more meaningful, and perhaps add additional test states for your new root object as well.
Adding and Maintaining Test Data
AFib allows you to construct a simple key-value store of test data within the test_data.dart
file. Although this store is trivial, it is useful because all of AFib's test types make it easy to refer to the data using its XXXTestDataID...
id.
This allows you to share data across tests easily. You saw examples of this above, where instead of constructing a stateView for each distinct UI prototype, I was able to specify XXXTestDataID.xxxStateFullLogin
as the state view.
As I mentioned above, the constants within XXXTestDataID
are strings.
Defining test data within test_data.dart
By default, AFib will create one subprocedure in test_data.dart
for each data type.
void defineTestData(AFDefineTestDataContext context) {
_defineUsersRoot(context);
_defineTodoItemsRoot(context);
}
Within that, you can register your own model objects using the define
function, and you can add them to one or more root objects.
void _defineUsersRoot(AFDefineTestDataContext context) {
// define some model objects.
final userChris = context.define<User>(TDLETestDataID.userChris, User(firstName: "Chris"... ));
final userKatherine = context.define<User>(TDLETestDataID.userKatherine, User(firstName: "Katherine"...));
// define some root objects if you like, this is the default that goes into your default
// 'full login' state.
context.define(TDLETestDataID.stateFullLoginCountInStateRoot, UsersRoot.fromList([
userChris,
userKatherine,
]));
}
If you need access to previously registered test data in a subsequent _define...
method, you can use the context.find
method:
void _defineSomeOtherRoot(AFDefineTestDataContext context) {
final userChris = context.find<User>(TDLETestDataID.userChris);
...
}
There is also a context.findList
method, which given a list of test IDs returns a list of the found test objects.
Here again, using resolve variables rather than direct object references makes it easier to maintain the test data, as the order in which test data is constructed is typically less important.