Skip to content

Latest commit

 

History

History
659 lines (555 loc) · 23 KB

File metadata and controls

659 lines (555 loc) · 23 KB

1.15.0 change details

Content:

Inject.previous Constructor

With the named constructor Inject.previous, you can inject and reinject objects by keeping track of their previous values before reinjection. Inject.previous is very useful when combined with the reinjectOn parameter.

Let's say we are in a shopping app, where a list of products will be displayed based on the authenticated user.

Our basic app will have an Auth class and a Products class. The Products class depends on the Auth class.

To inject the Products

 return Injector(
    inject: [
        //Injecting the Auth object
        Inject(()=>Auth()),
        Inject.previous(
            (Products previous) => Products(
                //used to fetch data in the Products object
                token: Injector.get<Auth>().token,
                // authenticated user ID
                userId: Injector.get<Auth>().userId,
                //List of all items we want to keep from the previous Products object
                items: previous.items ?? [], 
                //We can establish more then one dependence
                otherProperty: Injector.get<OtherModel>().otherProperty,
            ),
        ),
    ],
    //Here the connection between Products and Auth objects is established
    //Whenever authRM emits a notification, the injected Products instance  
    //is override by a new one as defined in the Inject.previous constructor.
    reinjectOn: [RM.get<Auth>(), RM.get<OtherModel>()],
    //shouldNotifyOnReinjectOn:true, //this is the default behavior
    builder: (_) => ....

 )

reinjectOn takes a list of ReactiveModels, so theoretically you can link Products to an infinite number of objects

By default the Products widget listeners will be notified when any of the models defined in the reinjectOn parameter issues a notification. To override this behavior set shouldNotifyOnReinjectOn parameter to false

Shortcuts; reducing boilerplate

As you know, for each injected model there are two lazily registered singletons: the pure object instance and the reactive one:

To consume the pure registered instance we use Injector.get<T>(). From this update on there is a shortcut to it: IN.get<T>(); IN stands for Injector

For the ReactiveModel, we used to use:

  • Injector.getAsReactive<T>() or ReactiveModel<T>(), to get the ReactiveModel instance of type T
  • ReactiveModel<T>.create(myModel) to create a local ReactiveModel
  • ReactiveModel<T>.future(myFuture) to create a local future ReactiveModel.
  • ReactiveModel<T>.stream(myStream) to create a local stream ReactiveModel.

Now as a Shortcuts:

  • RM.get<T>(), to get the ReactiveModel instance of type T.
  • RM.create<T>(myModel), to create a local ReactiveModel.
  • RM.future<T>(myFuture), to create a local future ReactiveModel.
  • RM.stream<T>(myStream), to create a local stream ReactiveModel.

To notify widget observers, we use setState method;

If you are to get the ReactiveModel and call setState only once, you can use the new shortcut:

  • RM.getSetState<T>((s)=>s.method()) this is a shortcut of :
  • RM.get<T>().setState((s)=>s.method())

Like FutureBuilder and StreamBuilder but more powerful

Fluter core API has FutureBuilder and StreamBuilder to handle futures and streams respectively.

class Foo {
    Future<userID> login() async {
        await api.login(token);
    }
}

In the UI and after instantiating the Foo object using our dependence injection (Global instance, InheretedWidget, or Provider, get_it, ..), we use FutureBuilder

FutureBuilder(
    future : foo.login();
    builder : (context, snapshot){
        if(snapshot.isWaiting){
            return SplashScreen();
        }
        if(snapshot.hasError){
            return Text('An Error has happened'),
        }
        if(snapshot.hasData){
            return Text('${snapshot.data}');    
        }
    }
)

In states_rebuilder world, after injection the Foo object using Injector, use can use one of the availbale wdiget:

  • StateBuilder, the default widget listener;
  • WhenRebuilder, to exhaustively go throw all the available state status (onIdle, onWaiting, onError, onData);
  • WhenRebuilderOr, to selectively go throw any of the available state status:
  • OnSetStateListener to execute side effects.

before this update:

WhenRebuilder<Foo>(
    models : [ReactiveModel<Foo>()],
    //we used the initState to trigger the future
    iniState : (ctx, fooRM) => fooRM.setState((s)=>s.login()),
    onIdle : ()=> Text('Welcoming Screen'),
    onWaiting : ()=> SplashScreen(),
    onError: (e)=> Text('An Error has happened'),
    onDate : (fooRM){
        return Text('${fooRM.state.userID}');
    }
)

Or; by creating a future ReactiveModel form the login method

WhenRebuilder<Foo>(
    models : [ReactiveModel.future<int>(Injector.get<Foo>().login())],
    onIdle : ()=> Text('Welcoming Screen'),
    onWaiting : ()=> SplashScreen(),
    onError: (e)=> Text('An Error has happened'),
    onData : (userIdRM){
      return Text('${userIdRM.value}');
    }
)

after this update:

WhenRebuilder<Foo>(
  //consider using observe instead of models see next section
    models : [RM.future<int>(Injector.get<Foo>().login())],
    onIdle : ()=> Text('Welcoming Screen'),
    onWaiting : ()=> SplashScreen(),
    onError: (e)=> Text('An Error has happened'),
    onData : (userId){
       return Text('${userIdRM}');
    }
)

in RM.future<int>(Injector.get<Foo>().login()) the first generic type (Foo) defines the type of the injected model, and the second generic type (int) defined the resulting type of the value resolved by the future.

The same thing is obtained for stream using RM.getStream

WhenRebuilder<int>(
    models : [RM.stream<int>(IN.get<Foo>().fireStoreStream())],
    onIdle : ()=> Text('Welcoming Screen'),
    onWaiting : ()=> SplashScreen(),
    onError: (error)=> Text('An Error has happened'),
    onData : (userId){
       return Text('${userId}');
    }
)

As I said you can use StateBuilder, WhenRebuilderOr, OnSetStateListener.

For performance consideration, observe and observeMany instead of models parameter

Although Flutter is performant by default, build () can be called frequently during the life cycle of the widget. We must be prepared for unwanted rebuilds.

In the case of FutureBuilder we read this caution in the Flutter docs:

The future must have been obtained earlier, ..... If the future is created at the same time as the FutureBuilder, then every time the FutureBuilder's parent is rebuilt, the asynchronous task will be restarted.

In many tutorials I see something like this :

FutureBuilder(
    future : foo.asyncMethod();
    builder : (context, snapshot){

    }
)

This is not good, because each time the build process is invoked the async method is called and the builder of the FutureBuilder is invoked.

With states_rebuilder the same unwanted behavior is expected:

StateBuilder(
    models : [RM.getFuture<Foo,void>((f)=> f.asyncMethod())],
    builder :(context, rm){

    }
)

states_rebuilder is a little better, when a parent of StateBuilder is rebuilding the builder of StateBuilder will not be called, but still the async method will be called.

To remedy this, the models parameter should be of void callback type:

StateBuilder(
    models : [() => RM.getFuture<Foo,void>((f)=> f.asyncMethod())],
    builder :(context, rm){

    }
)

By simply adding () => we gain performance and the future is entirely controlled by the StateBuilder widget and will never be called by an unwanted rebuilds.

In this context, I intended to add two parameters:

  • observe which is of type StatesRebuilder Function(). to register to one observable model.
  • observeMany which is of type List<StatesRebuilder Function()>. to register to more than one observable model.

justification of the chosen names:

  • As states_rebuilder use the observer pattern, observe is more descriptive than models.
  • both are verbs, because verbs are used for actions and callbacks are actions.
  • In most cases one will subscribe to one observable model, so it is convenient to use observe: ()=> .. instead of observe: [()=> ...]

With this new parameters :

WhenRebuilder<Foo>(
    observe : ()=> RM.getStream<Foo,int>((f)=> f.fireStoreStream()),
    onIdle : ()=> Text('Welcoming Screen'),
    onWaiting : ()=> SplashScreen(),
    onError: (error)=> Text('An Error has happened'),
    onData : (userId){
       return Text('${userId}');
    }
)

even with other types of ReactiveModel, observe is more efficient than models:

WhenRebuilder<Foo>(
    //old
    //models : [RM.get<Foo>()], // this get will be called for each parent rebuild.
    //new
    observe : () => RM.get<Foo>(),//this get is called once at the time of creation
    onIdle : ()=> Text('Welcoming Screen'),
    onWaiting : ()=> SplashScreen(),
    onError: (error)=> Text('An Error has happened'),
    onData : (foo){
       ...
    }
)

Think of the performance difference:

  • with models : [RM.get<Foo>()] the get method is called at the time of the creation and each time the parent widget rebuilds (hot restart, window sizing in web, routing, ...). If the invoked method does heavy calculation the performance loose is critical.
  • with observe : ()=> RM.get<Foo>() the get method is called only once at the time of creation.

observe and observeMany are experimental, and the models is maintained and may be deprecated in a future release.

ReactiveModel keys

states_rebuilder is based on the concepts of ReactiveModel.

A ReactiveModel can be injected and used when needed anywhere in the widget tree.

To get an injected model we use:

final modelRM = Injector.getAsReactive<T>();
// or more concisely:
final modelRM = ReactiveModel<T>();
// or even more concisely (since this release):
final modelRM = RM.get<T>();

In another hand, ReactiveModel can be created locally:

//creating a reactive model from a boolean value
final switchRM = ReactiveModel<bool>.create(true);
// or more concisely (since this release)
final switchRM = RM.create<bool>(true);

with states_rebuilder we can locally create ReactiveModel from primitive values, objects, futures or streams.

To consume the created ReactiveModel, we use one of the available widget observers : StateBuilder, WhenRebuilder, WhenRebuilderOr or OnSetStateListener.

Let's display a Flutter Switch button:

Scaffold(
    appBar: AppBar(
        title: StateBuilder<bool>(
        observe: () => RM.create(true),
        builder: (ctx, switchRM) {
            return Switch(
                value: switchRM.value,
                onChanged: (value) {
                    switchRM.value = value;
                },
            );
        },
        ),
    ),
    body: Container(),
)

One may argue that we can use a simple StatefulWidget!. Yes, but notice that with states_rebuilder the only part that rebuilds is the Switch button.

Let's complicate the situation a bit. Let's say we want two switches one on the appBar and the other in the bottomSheet and we want the two switches to be synchronized. Let's add a button in the center of the body to toggle the switches.

If using StatefulWidget, we must rebuild the entire Scaffold to fulfill the requirements.

With states_rebuilder and using the new concept of reactive model keys, we can limit the rebuild to the switches only.

Widget build(BuildContext context) {
//define a reactive model key with initial value of true
final switchKey = RMKey(true);
return MaterialApp(
   home: Scaffold(
    appBar: AppBar(
        title: StateBuilder<bool>(
        observe: () => RM.create(true),
        //assign the key to this StateBuilder widget
        rmKey: switchKey,
        builder: (ctx, switchRM) {
            return Switch(
            value: switchRM.value,
            onChanged: (value) {
                switchRM.value = value;
            },
            );
        },
      ),
    ),
    body: Center(
        child: RaisedButton(
        child: Text('Toggle'),
        onPressed: () {
            //set the value and notify listeners
            switchKey.value = !switchKey.value ;
        },
      ),
    ),
    bottomSheet: StateBuilder<bool>(
        //subscribe the this StateBuilder using the defined key
        observe: () => switchKey,
        builder: (ctx, switchRM) {
        return Switch(
            value: switchRM.value,
            onChanged: (value) {
               switchRM.value = value;
            },
        );
      },
    ),
  ),
 );
}

Similar to global keys in flutter, reactive model key (RMKey) are used to control a state_builder observer widget from outside.

final switchKey = RMKey(true);

Here we created a ReactiveModel key and initialize it to be true.

    title: StateBuilder<bool>(
    observe: () => RM.create(true),
    //assign the key to this StateBuilder widget
    rmKey: switchKey,
    builder: (ctx, switchRM) {
        return Switch(
        value: switchRM.value,
        onChanged: (value) {
            switchRM.value = value;
        },
        );
    },
    ),

The StateBuilder has a new parameter called rmKey that receives the defined RMKey.

By assigning a RM Key to a StateBuilder widget, we can notify it to rebuild from outside the builder of the StateBuilder widget.

RMKey inherits all The ReactiveModel functionality, such as setState, setValue, and state, value getters.

RaisedButton(
    child: Text('Toggle'),
    onPressed: () {
        //set the value and notify listeners
        switchKey.value = !switchKey.value ;
    },
),

You can even subscribe widgets to a RMKey so that wehner the RMKey or the ReactiveModel associated with it emits a notification, the subscribed widget will rebuild:

bottomSheet: StateBuilder<bool>(
    //subscribe the this StateBuilder using the defined key
    observe: () => switchKey,
    builder: (ctx, switchRM) {
    return Switch(
        value: switchRM.value,
        onChanged: (value) {
            switchRM.value = value;
        },
    );
    },
),

RMKey has a new functionality called refresh. Let create a future like we used in the example above.

//createa RMKey
final fetchProductsKey = RMKey();
//..
StateBuilder(
    models : [() => RM.getFuture<Foo,void>((f)=> f.fetchProducts())],
    //associate it with this widget
    rmKey: fetchProductsKey
    builder :(context, rm){

    }
)

When the StateBuilder widget is inserted in the widget tree, the fetchProducts() is invoked to get the list of products and the builder closure of the StateBuilder widget will be called once the future resolves.

What if we want to refresh the list of products and call fetchProducts() again.

With the concept of RMKey, we just call the builtin refresh method:

RaisedButton(
    child: Text('refresh products'),
    onPressed: () {
        fetchProductsKey.refresh();
    },

If you have a counter with RMKey, when refresh(), is called the counter is reset to its initial value.

Working with immutable state

Immutable state has its fun and advantages, with this update you can work with immutable states just as fine as you do with mutable state.

Let's see this imaginary example: Fetching for a list of products and add a product to the list and persist the new list.

This is the fake repository used to fetch and persist the list of products

//fake repository
List<Product> _products = [Product('prod1'), Product('prod2')];
Future<List<Product>> fetchProductsRepo() {
  //simulate a delay
  return Future.delayed(
    Duration(seconds: 1),
    () {
      //simulate an error
      if (Random().nextBool()) {
        throw Exception('A Custom Message');
      }
      return _products;
    },
  );
}

Future addProductRepo(Product product) async {
  //simulate a delay
  await Future.delayed(
    Duration(seconds: 1),
    () {
      //simulate an error
      if (Random().nextBool()) {
        throw Exception('A Custom Message');
      }
      _products.add(product);
    },
  );
}

This is the simple Product model

//Product model
@immutable
class Product {
  final name;
  Product(this.name);
}

This is the immutable list Products state class

@immutable
class ProductsState {
  final List<Product> products;

  ProductsState(this.products);

  //methods in the ProductsState must return a new state of ProductsState.

  //We use Future want to show a CircularProgressIndicator while waiting for Products,
  Future<ProductsState> getProducts() async {
    final result = await fetchProductsRepo();
    return ProductsState(result);
  }

  //We use stream generator because we want to yield back the old state on error
  Stream<ProductsState> addProduct(Product product) async* {
    //yield the new state so States_rebuilder will update the UI to display it,
    yield ProductsState([...products, product]);
    try {
      await addProductRepo(product);
    } catch (e) {
      //yield the old state
      yield this;
      rethrow;
    }
  }
}

Notice that methods in the ProductsState are the event that trigger the transform from the actual state to the next state. Methods in the ProductState must returns a new instance of ProductsState.

In fetchProducts we returned Future because we want to wait for the asynchronous method to end and display a CircularProgressIndicator.

Whereas in addProduct we used a stream generator because we want to instantly display the new list (yield the new list) and execute in the background the asynchronous method. It is if the asynchronous method ends with an error, then we want to display the old list (yield the old list) and display a snackBar of the error.

Notice that in the same fashion as with mutable state, the business logic is a simple dart class.

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Injector(
      //inject ProductsState with empty list of product (this is the initial state)
      inject: [Inject(() => ProductsState([]))],
      builder: (context) {
        return MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              child: WhenRebuilderOr<ProductsState>(
                //get the registered instance of ProductsState ReactiveModel
                observe: () => RM.get<ProductsState>()
                //use the cascade operator and invoke the future method to call getProducts
                  ..future(
                    (productState) => productState.getProducts(),
                    //If the future ends with error show an alert dialog
                  ).onError(_showErrorDialog),
                //While waiting for the future to end, display a CircularProgressIndicator
                onWaiting: () => Center(
                  child: CircularProgressIndicator(),
                ),
                builder: (context, productStateRM) {
                  final products = productStateRM.value.products;
                  return ListView.builder(
                    itemCount: products.length,
                    itemBuilder: (context, index) {
                      return ListTile(
                        title: Text('${products[index].name}'),
                      );
                    },
                  );
                },
              ),
            ),
            floatingActionButton: FloatingActionButton(
              child: Icon(Icons.add),
              onPressed: () {
                final newProduct = Product('prod ${Random().nextInt(100)}');
                //get the registered ProductsState ReactiveModel
                RM.get<ProductsState>()
                  //invoke the stream method and define the error side effect
                  .stream(
                    (productState) => productState.addProduct(newProduct),
                  ).onError(_showSnackBar);
              },
            ),
          ),
        );
      },
    );
  }

  void _showErrorDialog(BuildContext context, dynamic error) {
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          content: Text('error.message'),
        );
      },
    );
  }

  void _showSnackBar(BuildContext context, dynamic error) {
    Scaffold.of(context).showSnackBar(
      SnackBar(
        content: Text('error.message'),
      ),
    );
  }
}

First, we injected ProductsState object with an initial state of an empty list.

 //inject ProductsState with empty list of product (this is the initial state)
      inject: [Inject(() => ProductsState([]))],

Then we called the fetchProducts method form the observe parameter of the WhenRebuilderOr widget.

observe: () => RM.get<ProductsState>()
//use the cascade operator and invoke the future method to call getProducts
    ..future(
    (productState) => productState.getProducts(),
    //If the future ends with error show an alert dialog
    ).onError(_showErrorDialog),
  • By RM.get<ProductsState>() we get the actual registered ReactiveModel of the state in the service locator.
  • By ..future((productState) => productState.getProducts(),) we triggered the getProducts method add told state_builder that this method is a future, so state_builder changes the state status of the ReactiveModel to isWaiting to display the CircularProgressIndicator and when the future ends with data, state_builder change the registered instance in the service locator to hold the new state and notify observing widgets.
  • By .onError(_showErrorDialog), we told state_builder what to do if the future ends with and error.

See docs for more information on error handling.

To add a product in the onPressed callback :

onPressed: () {
final newProduct = Product('prod ${Random().nextInt(100)}');
//get the registered ProductsState ReactiveModel
RM.get<ProductsState>()
    //invoke the stream method and define the error side effect
    .stream(
    (productState) => productState.addProduct(newProduct),
    ).onError(_showSnackBar);
},
  • By RM.get<ProductsState>() we get the actual registered ReactiveModel of the state in the service locator.
  • By .stream((productState) => productState.addProduct(newProduct),) we told state_builder that addProduct is a stream so it will subscribe to and notify observing widget on data emitting (yield).
  • By .onError(_showSnackBar), we told state_builder what to do if the stream emits an error.

Stream are automatically disposed of after they are done or when the ReactiveModel is disposed. So do not fear memory leakage.