Skip to content

Dart Essentials

If you already know a C-syntax language, I think you will find that Dart is extremely easy to pick up.

There are good tutorials available from Google and others if you would like a broad overview. This section covers features of Dart which are especially useful in AFib.

Parameter Passing Syntax

I love the flexibility and brevity of dart parameter passing. I find it makes code easy to read and refactor.

Positional Parameters

Dart supports standard positional parameters:

String fullName(String firstName, String lastName) {
    return "$firstName $lastName";
}

int main() {
    final result = fullName("Irving", "Washington");
    return 0;
}

Named Parameters

But it also supports named parameters, which can be declared in curly braces:

String fullName({
  required String firstName, // required means the caller must specify the value.
  required String lastName,
}) {
    return "$firstName $lastName";
}

int main() {
    // its so easy to understand the code when you can see the parameter names in the call!
    final result = fullName(firstName: "Irving", lastName: "Washington");
    // or
    final sameResult = fullName(lastName: "Washington", firstName: "Irving");
    return 0;
}

Named parameters do not need to be specified, in which case they are automatically null:

String fullName({
    required String lastName,
    required String firstName, 
    String? title, // the question mark indicates it can be null
}) {
    final result = StringBuffer();
    if(title != null) {
        result.write("$title ");
    }
    result.write("$firstName ");
    result.write(lastName);
    return result.toString();
}

int main() {
    // Mr. Irving Washington
    final result = fullName(title: "Mr.", firstName: "Irving", lastName: "Washington");

    // Irving Washington
    final noPrefix = fullName(lastName: "Washington", firstName: "Irving");
    return 0;
}

Named and positional parameters can also be used in class declarations. The concise this.memberVariable shorthand is used to initialize a member variable from a constructor parameter.

class User {
    final String firstName;
    final String lastName;
    final String? title; // ? means title can be null.
    MyClass({
        required this.firstName,
        required this.lastName,
        this.title,
    });
}

int main() {
    final inst = User(title: "Mr.", firstName: "Irving", lastName: "Washington");

    // title is null for this one.
    final otherInst = User(lastName: "Washington", firstName: "Irving");
}

Nullability

Declaring a variable is nullable (or not)

Dart tracks whether variables can be null, and prevents null dereferences. You declare a variable nullable by adding a question mark to its type:

class Example {
    int myNotNullInt; // this cannot be null;
    int? myNullInt; // this can be null;
    Example({
        required this.myInt, // this is marked required because you have to specify it.
        this.myNullInt, // this need not be specified by the caller, in which case it is just null.
    })

    void myFunction() {
        int? count; // again, this can be null.
        int otherCount = 1; // this cannot be.
    }
}

Handy Null-Aware Operators

The ?? operator

This operator is very important in immutable data structures, as described below. In this statement:

    final result = primary ?? backup;
result will contain the value of primary if it is not null, and backup if primary is null.

You can also use this operator with constants:

    return colorThatMightBeNull ?? Colors.red; // this return value is certain not to be null

The ??= operator

This operator does an assignment only if the destination is null. It saves you a conditional:

// this is more verbose.
if(x == null) {
    x = 5;
}

// this does the same thing more concisely
x ??= 5; // assignment only occurs if x is null.

You cannot null check member variables

If you perform a null check on a local variable, Dart will understand that the variable cannot be null:

int nullTest(int? val) {
    final wontWork = val+5; // this is a compiler error, val could be null.
    if(val == null) {
        return 0;
    }

    // this is ok, dart knows val is not null.  It is treated as an int, not an int?.
    return val+5;
}

However, this null check won't work on member variables:

class MyTest {
    final int? value;
    MyTest({ this.value });

    int nullTest() {
        if(value == null) {
            return 0;
        }
        return value+5; // THIS IS STILL AN ERROR!  Dart doesn't trust that member variables won't change.
    }
}
This limitation exists because 'member variables' are indistiguishable from dynamically calculated accessor methods, and there is no guarantee those will stay non-null from call to call. So, you need to put the value of the member variable into a local variable first.

class MyTest {
    final int? value;
    MyTest({ this.value });

    int nullTest() {
        // if 'value' is a dynamically calculated accessor method, now its value is 
        // captured in localValue and cannot change.
        final localValue = value; 
        if(localValue == null) {
            return 0;
        }
        return localValue+5; // now this works, localValue is known to be not null.
    }
}

Immutable Data Structures

AFib uses a redux-inspired immutable state. The dart languge provides excellent support for maintaining immutable data structures.

The @immutable tag, and final member variables

When you add the @immutable tag to a class, you are indicating that all its member variables should be final, meaning they cannot be changed.

@immutable
class StartupScreenRouteParam extends ARouteParam {
    final int clickCount; // compiler error if this is not final.
    final int otherCount;
    StartupScreenRouteParam({
        required this.clickCount, // final variables must be initialized by the constructor
        required this.otherCount
    });

    void notAllowed() {
        clickCount = 99; // this is a compiler error, you can't modify a final variable.
    }

Importing flutter rather than meta

In order to get access to the @immutable tag, you should use:

import 'package:meta/meta.dart';
Note that VS Code's quick fix often suggests you should import flutter, which will work, but which is undesirable because importing flutter into any code referenced from the command line (e.g. bin/xxx_afib.dart) will cause the command line app to fail to load.

Calculating the Value of Final Variables

The values of final variables must be passed as parameters to the constructor, they cannot be calculated within the constructor body:

class ImmutableTest {
    final Map<String, User> values;

    // this is not allowed.
    ImmutableTest(List<User> users) {
        this.values = _convertToMap(users); // this is not allowed.
    }

    // for a final value, you have to write the constructor like this.
    // values must be passed in from the outside.
    ImmutableTest(this.values);

    // But that is undesirable, because now the list -> Map conversion must be done by the
    // caller.   The obvious soluton is a static method that takes a list, does the conversion
    // to a map, and then calls the constructor.   Dart has a special syntax for exactly that:
    factory ImmutableTest.fromList(List<User> users) {
        final vals = _convertToMap(users);
        return ImmutableTest(vals); 
    }

    // in code, you construct an immutable test like:
    // final result = ImmutableTest.fromList(users);
    // its really just a static method with a fixed return type.
}

As shown above, Dart has a special form of static method called a factory which is useful for just this case. Its basically a static method that allows you to keep internal data structures encapsulated in the class while also satisfying the requirement that final values must be passed in as constructor parameters.

The copyWith method

When we modify an immutable data structure, we often want to modify a small part of the data structure, and leave everything else the same. Dart syntax makes this easy. By convention, we do this with a copyWith method. Continuing the StartupScreenRouteParam class above:

    StartupScreenRouteParam copyWith({
        int? clickCount,
        int? otherCount,
    }) {
        return StartupScreenRouteParam(
            // if clickCount is specified, it is non-null and will be used, 
            // otherwise, we will maintain the current value of this.clickCount
            clickCount: clickCount ?? this.clickCount, 
            otherCount: otherCount ?? this.otherCount,
        );
    }
If I have an instance of a startup screen route parameter, then I can use this call to modify clickCount, but leave otherCount unchanged:

    final revised = param.copyWith(clickCount: 100);

Use constant flag values rather than null

Note that one limitation of the copyWith syntax is that it will not allow you to set a value to null. Consider this example, where the filter member variable is a flag indicating how to filter some list of items by priority. Initially, you choose to use null to indicate your list is unfiltered:

class ExampleRouteParam extends AFRouteParam {
    static const int filterHighPriority = 1;
    static const int filterLowPriority = 2;
    final int? filter;

    ...

    ExampleRouteParam copyWith({
        int? filter
    }) {
        return ExampleRouteParam(
            filter: filter ?? this.filter
        );
    }

The problem with this is that once your route parameter has a non-null filter value, you cannot easily set it back to null (meaning unfiltered) with the default copyWith syntax. The statement filter ?? this.filter will apply this.filter if the filter param is null, which happens both when you do not specify it at all (e.g. copyWith()) and when you explicitly specify it as null (e.g. copyWith(filter: null)). So, if this.filter is currently filterHighPriority, or 1, and you would like to set it to null meaning unfiltered, then calling:

    final revised = example.copyWith(filter: null);

Won't do anything by design. In revised, the value of the filter member variable will continue to be 1.

One way to work around this is to use a constant flag value in place of whatever you meant by the null value. For example. if you added

    const int filterNone = 3;
    ...
    final revised = example.copyWith(filter: filterNone);

This will work, because filterNone is non-null, and thus will be applied within copyWith instead of this.filter. Using constant flag values like this is often simpler and clearer than using null to indicate a specific state.

You can call your copyWith method directly. However, I find that over time, as objects grow complex, you often need to coordinate the modification of several variables in an object. Consequently, I like to hide the call to copyWith in higher level revise methods. For example:

StartupScreenRouteParam reviseIncrementClickCount() {
    return copyWith(clickCount: clickCount+1);
}

Although this example is simple, I find that it multi-contributor projects, being able to easily find all the established ways of revising an existing object by using the item.revise... typeahead is very useful. It often avoids bugs which would have occurred if a new developer did not realize that revising one part of an object required compatible revisions to some other part.

Immutability and Dart's Standard Data Structures

You can use the static from method to make a copy of built in Dart data structures. For example:

    List<int> revisePop(List<int> source) {
        final result = List<int>.from(source); // this makes a copy.
        result.pop();
        return result;
    }

Note that List is a special case, you can use the 'toList()' method to make a copy as well:

    List<int> revisePop(List<int> source) {
        final result = source.toList(); // also makes a copy.
        result.pop();
        return result;
    }

Declaring data structures unmodifiable

Note that final does not mean constant, you can still do this:

@immutable
class ThisCouldBeConfusing {
    final List<int> numbers;
    ThisCouldBeConfusing(this.numbers);

    // the compiler will error for this:
    void notAllowed() {
        numbers = <int>[];  // you cannot modify the final variable.
    }

    // but it will allow this
    void allowedButNotRightWayToPush(int value) {
        numbers.push(value); // you didn't modify the final variable, this is allowed.
        // but it is not right, and your UI won't update properly if you do stuff like
        // this in your route param or state, because you modified the object without
        // modifying its pointer value by copying it.
    }

    // The correct way is to do a copyWith/revise method that makes a copy of this
    // object, but with a new modified array:
    ThisCouldBeConfusing revisePush(int value) {
        final revised = numbers.toList();
        revised.push(value);

        // this will return a new pointer value, which is how AFib/redux determines
        // that an object in the state has been revised.  It does not do deep equality,
        // just pointer equality.
        return copyWith(numbers: revised);
    }

}
I don't personally have much trouble remembering this. But if you worry folks in your organization will, most built-in data structures have a way to construct an unmodifiable variant:

    final cantModify = List.unmodifiable(numbers);
    // this will throw an exception.
    cantModify.push(5)

Function Handling

Functions are First Class Objects

You can pass functions around and reference them just like other data. For example:

    // no need to create a whole closure here:
    return FlatButton(
        ...
        onPressed: () {
            spi.onPressedButton();
        }
    )

    // do this instead.
    return FlatButton(
        ...
        onPressed: spi.onPressedButton
    )

onPressedButton is a function in the SPI. You don't need to wrap the call in a closure, you can just pass the SPI's function directly to the onPressed handler.

Dart has the => operator

class SimplifyIt {

    // this is more verbose.
    int addVerbose(int x, int y) {
        return x+y;
    }

    // concise version of the same thing, the return is implicit.
    int add(int x, int y) => x+y;
}

Dart has accessor methods, which act like member variables

Here, it looks like fullName is just a member variable.

    String get fullName {
        return "$firstName $lastName";
    }

    // of course, this is more concise:
    String get fullName => "$firstName $lastName";

It is often nice to use these in SPIs to expose relatively simple calculated values, for example:

    // the idea here is the the route param contains the kind of todo items we are showing (e.g. pending, completed),
    // the state view contains all the todoItems, which expose a method for filtering by a desired kind.
    // However, in UI code, we can just access spi.visibleItems as though it was a member variable.
    List<TodoItem> get visibleItems => context.s.todoItems.filterForKind(context.p.showKind);