states_rebuilder 4.4.0-dev-1 states_rebuilder: ^4.4.0-dev-1 copied to clipboard
a simple yet powerful state management technique for Flutter
states_rebuilder
#
- Performance - Predictable and controllable state mutation - Immutable / Mutable states support - Strictly rebuild control - Auto clean state when not used
-
Code Clean
- Zero Boilerplate
- No annotation & code-generation
- Separation of UI & business logic
- Achieve business logic in pure Dart.
-
User Friendly
- Built-in dependency injection system
SetState
in StatelessWidget.- Hot-pluggable Stream / Futures
- Easily Undo / Redo
- Elegant error handling and refreshing
- Navigate, show dialogs without
BuildContext
- Named route with dynamic segmenet
- Nested routes mapping
- Easily persist the state and retrieve it back
- Override the state for a particular widget tree branch (widget-wise state)
-
development-time-saving
- Easily CREATE, READ, UPDATE, and DELETE (CRUD) from rest-API or database.
- Easy user authentication and authorization.
- Easily app themes management.
- Simple internalization and localization.
- Work with TextFields and Form validation, both in client and server side.
- Implicit animation with the power of explicit animation.
- Easy and user friendly interface for ScrollControllers
-
Maintainable
- Easy to test, mock the dependencies
- state tracker middleware
- Built-in debugging print function
- Capable for complex apps
Table of Contents #
- Getting Started with States_rebuilder
- Breaking Changes
- A Quick Tour of states_rebuilder API
- Examples:
Getting Started with States_rebuilder #
-
Add the latest version to your package's pubspec.yaml file.
-
Import it in any Dart code:
import 'package:states_rebuilder/states_rebuilder.dart';
- Basic use case:
// ποΈPlain Data Class
class Model {
int counter;
Model(this.counter);
}
// π€Business Logic - Service Layer
extension ModelX on Model {
increment() => this.counter++;
}
// πGlobal Functional Injection
// This state will be auto-disposed when no longer used, and also testable and mockable.
final model = RM.inject<Model>(
() => Model(0),
undoStackLength: 8,
//Called after new state calculation and just before state mutation
middleSnapState: (MiddleSnapState middleSnap){
//Log all state transition.
print(middleSnap.currentSnap);
print(middleSnap.nextSnap);
middleSnap.print()//Build-in logger
//Can return another state
}
);
// πUI
class CounterApp extends StatelessWidget {
const CounterApp();
@override
Widget build(BuildContext context) {
return Column (
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
child: const Text('ποΈ Counter ++'),
onPressed: () => model.setState(
(s) => s.increment(),
),
),
RaisedButton(
child: const Text('β±οΈ Undo'),
onPressed: () => model.undoState(),
),
On(
() => Text('πResult: ${model.state.counter}'),
).listenTo(model),
],
);
}
}
Breaking Changes #
Since 4.0: Here #
Since 3.0: Here #
Since 2.0: Here #
A Quick Tour of states_rebuilder API #
Business logic #
The business logic classes are independent from any external library. They are independent even from
states_rebuilder
itself.
The specificity of states_rebuilder
is that it has practically no boilerplate. It has no boilerplate to the point that you do not have to monitor the asynchronous state yourself. You do not need to add fields to hold for example onLoading
, onLoaded
, onError
states. states_rebuilder
automatically manages these asynchronous statuses and exposes the isIdle
, isWaiting
, hasError
and hasData
getters and onIdle
, onWaiting
, onError
and onData
hooks for use in the user interface logic.
With
states_rebuilder
, you write business logic without bearing in mind how the user interface would interact with it.
This is a typical simple business logic class:
class Foo { //don't extend any other library specific class
int mutableState = 0; // the state can be mutable
//Or
final int immutableState; // Or it can be immutable (no difference)
Foo(this.immutableState);
Future<int> fetchSomeThing async(){
//No need for any kind of async state tracking variables
return repository.fetchSomeThing();
//No need for any kind of notification
}
Stream<int> streamSomeThing async*(){
//Methods can return stream, future, or simple sync objects,
//states_rebuilder treats them equally
}
}
To make the Foo
object reactive, we simply inject it using global functional injection:
final Injected<Foo> foo = RM.inject<Foo>(
()=> Foo(),
onInitialized : (Foo state) => print('Initialized'),
// Default callbacks for side effects.
onSetState: On.all(
onIdle: () => print('Is idle'),
onWaiting: () => print('Is waiting'),
onError: (error) => print('Has error'),
onData: (Foo data) => print('Has data'),
),
// It is disposed when no longer needed
onDisposed: (Foo state) => print('Disposed'),
// To persist the state
persist:() => PersistState(
key: '__FooKey__',
toJson: (Foo s) => s.toJson(),
fromJson: (String json) => Foo.fromJson(json),
// Optionally, throttle the state persistance
throttleDelay: 1000,
),
// middleSnapState as a middleWare place
// Used to track and log state lifecycle and transitions.
// It can also be used to return another state created from
// the current state and the next state.
middleSnapState: (middleSnap) {
middleSnap.print(); //Build-in logger
// Example of simple email validation
if (middleSnap.nextSnap.hasData) {
if (!middleSnap.nextSnap.data.contains('@')) {
return middleSnap.nextSnap.copyToHasError(
Exception('Enter a valid Email'),
);
}
}
},
);
//For simple injection you can use `inj()` extension:
final foo = Foo().inj<Foo>();
final isBool = false.inj();
final string = 'str'.inj();
final count = 0.inj();
Injected
interface is a wrapper class that encloses the state we want to inject. The state can be mutable or immutable.
Injected state can be instantiated globally or as a member of classes. They can be instantiated inside the build method without losing the state after rebuilds.
To inject a state, you use
RM.inject
,RM.injectFuture
,RM.injectStream
orRM.injectFlavor
.
The injected state even if it is injected globally it has a lifecycle. It is created when first used and destroyed when no longer used. Between the creation and the destruction of the state, it can be listened to and mutated to notify its registered listeners.
When the state is disposed of, its list of listeners is cleared, and if the state is waiting for a Future or subscribed to a Stream, it will cancel them to free resources.
Injected state can depend on other Injected states and recalculate its state and notify its listeners whenever any of its of the Inject model that it depends on emits a notification.
π See more detailed information about the RM.injected API.
To mutate the state and notify listener:
//Inside any callback:
foo.state= newFoo;
//Or for more options
foo.setState(
(s) => s.fetchSomeThing(),
onSetState: On.waiting(()=> showSnackBar() ),
debounceDelay : 400,
)
//if state is bool
foo.toggle();
The state when mutated emits a notification to its registered listeners. The emitted notification has a boolean flag to describe is status :
isIdle
: the state is first created and no notification is emitted yet.isWaiting
: the state is waiting for an async task to end.hasError
: the state mutation has ended with an error.hasData
: the state mutation has ended with valid data.
states_rebuilder offers callbacks to handle the state status change. The state status callbacks are conveniently defined using the On
class with its named constructor alternatives:
// Called when notified regardless of state status of the notification
On(()=> print('on'));
// Called when notified with data status
On.data(()=> print('data'));
// Called when notified with waiting status
On.waiting(()=> print('waiting'));
// Called when notified with error status
On.error((err, refresh)=> print('error'));
// Exhaustively handle all four status
On.all(
onIdle: ()=> print('Idle'), // If is Idle
onWaiting: ()=> print('Waiting'), // If is waiting
onError: (err, refresh)=> print('Error'), // If has error
onData: ()=> print('Data'), // If has Data
)
// Optionally handle the four status
On.or(
onWaiting: ()=> print('Waiting'),
onError: (err, refresh)=> print('Error'),
onData: ()=> print('Data'),
or: () => print('or')
)
//Used to listen to a future.
On.future<F>(
onWaiting: ()=> Text('Waiting..'),
onError: (error, refresher) => Text('Error'),//Future can be reinvoked
onData: (data)=> MyWidget(),
).future(()=> anyKindOfFuture);
//This widget subscribes to the `stateAsync` of the injected model.
//This is a one-time subscription of the `onWaiting` and `onError` and
//ongoing subscription of `onData`
On.future<F>(
onWaiting: ()=> Text('Waiting..'),//One-time subscription
//On error, future can be reinvoked
onError: (error, refresher) => Text('Error'),//One-time subscription
onData: (data)=> MyWidget(),//Ongoing subscription
).listenTo(model);
///Used with injectedCRUD. It watches the state of the backend service
On.crud(
onWaiting: ()=> Text('Waiting..'),//The querying is ongoing
onError: (error, refresh) => Text('Error'),// The querying fails
onResult: (result)=> MyWidget(),// The querying succeeds
).listenTo(injectedCRUD);
///Used with injectedAuth. It displays the right page depending on the signed user
On.auth(
onWaiting: ()=> Text('Waiting..'),
onUnsigned: ()=> AuthPage(),
onSigned: ()=> HomeUserPage(),
).listenTo(injectedAuth);
///Listen to an injectedAnimation and set the animation tween implicitly of explicitly
On.animation(
(animate) => Container(
width: animate.call(selected ? 200.0 : 100.0),
color: animate(selected ? Colors.red : Colors.blue),
alignment: animate(selected ? Alignment.center : AlignmentDirectional.topCenter),
child: const FlutterLogo(size: 75),
),
).listenTo(animation)
///List to an InjectedForm and control TextField inside its callback
On.form(
() => Column(
children: <Widget>[
TextField(
focusNode: email.focusNode,
controller: email.controller,
onSubmitted: (_) {
password.focusNode.requestFocus();
},
),
TextField(
focusNode: password.focusNode,
controller: password.controller,
onSubmitted: (_) {
form.submitFocusNode.requestFocus();
},
),
],
),
).listenTo(form),
///Listen to an InjectedScrolling
On.scroll(
(scroll) {
if (scroll.isScrolling) {
//While scrolling return an empty container
return Container();
}
return FloatingActionButton(
onPressed: () {},
);
},
).listenTo(scroll),
All onError callbacks expose a refresher. It can be use to refresh the error; that is recalling the last function that caused the error.
π See more detailed information about setState API.
You can notify listeners without changing the state using :
foo.notify();
You can also refresh the state to its initial state and reinvoke the creation function then notify listeners using:
foo.refresh();
refresh
is useful to re-execute async data fetching to get the updated data from a server. Typical use is the refresh a ListView display.
If the state is persisted, calling refresh
will delete the persisted state and replace it with the newly created one.
Calling refresh
will cancel any pending async task from the state before refreshing.
π See more detailed information about the refresh API.
UI logic #
-
To listen to an injected state from the User Interface:
- For general use and full options use:
On.all( onIdle: ()=> Text('Idle'), onWaiting: ()=> Text('Waiting'), onError: (err, refresh)=> Text('Error'), onData: ()=> Text('Data'), ).listenTo( foo, //Listen to foo state //called once the widget is inserted initState: ()=> print('initState'), //called once the widget is removed dispose: ()=> print('dispose'), //called after notification and before rebuild onSetState: On.error((err) => print('error')), //called after notification and rebuild onAfterBuild: On(()=> print('After build')), ) ```
- Rebuild when model has data only:
// Equivalent to On.data foo.rebuilder(()=> Text('${model.state}'));
- Handle all possible async status:
// Equivalent to On.all foo.whenRebuilder( isIdle: ()=> Text('Idle'), isWaiting: ()=> Text('Waiting'), hasError: ()=> Text('Error'), hasData: ()=> Text('Data'), )
- Listen to a future from
foo
and notify this widget only.foo.futureBuilder<T>( future: (state, stateAsync)=> state.fetchSomeThing(), onWaiting: ()=> Text('Waiting..'), onError: (err) => Text('Error'), onData: (T data) => Text(data), )
- Listen to a stream from
foo
and notify this widget only.foo.streamBuilder<T>( stream: (state, subscription)=> state.streamSomeThing(), onWaiting: ()=> Text('Waiting..'), onError: (err) => Text('Error'), onData: (T data) => Text(data), onDone: ()=> Text('Done'), )
- For general use and full options use:
-
To listen to many injected models and expose a merged state:
OnCombined.all( isWaiting: ()=> Text('Waiting'),//If any is waiting hasError: (err, refresh)=> Text('Error'),//If any has error isIdle: ()=> Text('Idle'),//If any is Idle hasData: (data)=> Text('Data'),//If all have Data ).listenTo([model1, model1 ..., modelN]);
π See more detailed information about the widget listeners.
-
To undo and redo immutable state:
model.undoState(); model.redoState();
-
To navigate, show dialogs and snackBars without
BuildContext
:RM.navigate.to(HomePage()); RM.navigate.to('/namePage'); RM.navigate.toDialog(AlertDialog( ... )); RM.scaffoldShow.snackbar(SnackBar( ... ));
You can easily change page transition animation, using one of the predefined TransitionBuilder or just define yours.
You can use dynamic segments with named routing
return MaterialApp( navigatorKey: RM.navigate.navigatorKey, onGenerateRoute: RM.navigate.onGenerateRoute({ '/': (_) => LoginPage(), '/posts': (_) => RouteWidget( routes: { '/:author': (RouteData data) { final queryParams = data.queryParams; final pathParams = data.pathParams; final arguments = data.arguments; //OR //Inside a child widget of AuthorWidget : // //context.routeQueryParams; //context.routePathParams; //context.routeArguments; return AuthorWidget(); }, '/postDetails': (_) => PostDetailsWidget(), }, ), '/settings': (_) => SettingsPage(), }), );
In the UI:
RM.navigate.to('/'); // => renders LoginPage() RM.navigate.to('/posts'); // => 404 error RM.navigate.to('/posts/foo'); // => renders AuthorWidget(), with pathParams = {'author' : 'foo' } RM.navigate.to('/posts/postDetails'); // => renders PostDetailsWidget(), //If you are in AuthorWidget you can use relative path (name without the back slash at the beginning) RM.navigate.to('postDetails'); // => renders PostDetailsWidget(), RM.navigate.to('postDetails', queryParams : {'postId': '1'}); // => renders PostDetailsWidget(),
-
To Persist the state and retrieve it when the app restarts,
final model = RM.inject<MyModel>( ()=>MyModel(), persist:() => PersistState( key: 'modelKey', toJson: (MyModel s) => s.toJson(), fromJson: (String json) => MyModel.fromJson(json), //Optionally, throttle the state persistance throttleDelay: 1000, ), );
You can manually persist or delete the state
model.persistState(); model.deletePersistState();
-
To Create, Read, Update and Delete (CRUD) from backend or DataBase,
final products = RM.injectCRUD<Product, Param>( ()=> MyProductRepository(),//Implements ICRUD<Product, Param> readOnInitialization: true,// Optional (Default is false) );
//READ products.crud.read(param: (param)=> NewParam()); //CREATE products.crud.create(NewProduct()); //UPDATE products.crud.update( where: (product) => product.id == 1, set: (product)=> product.copyWith(...), ); //DELETE products.crud.delete( where: (product) => product.id == 1, isOptimistic: false, // Optional (Default is true) );
-
To authenticate and authorize users,
final user = RM.injectAuth<User, Param>( ()=> MyAuthRepository(),//Implements IAuth<User, Param> unSignedUser: UnsignedUser(), onSigned: (user)=> //Navigate to home page, onUnsigned: ()=> //navigate to Auth Page, autoSignOut: (user)=> Duration(seconds: user.tokenExpiryDate) );
//Sign up user.auth.signUp((param)=> Param()); //Sign in user.auth.signIn((param)=> Param()); //Sign out user.auth.signOut();
-
Widget-wise state (overriding the state):
final items = [1,2,3];
final item = RM.inject(()=>null);
class App extends StatelessWidget{
build (context){
return ListView.builder(
itemCount: items.length,
itemBuilder: (BuildContext context, int index) {
return item.inherited( //inherited uses the InheritedWidget concept
stateOverride: () => items[index],
builder: () {
return const ItemWidget();
//Inside ItemWidget you can use the buildContext to get
//the right state for each widget branch using:
item.of(context); //the Element owner of context is registered to item model.
//or
item(context) //the Element owner of context is not registered to item model.
}
);
},
);
}
}
π See more detailed information about the topic of state widget-wise and InheritedWidget.
-
To dynamically switch themes,
final theme = RM.injectTheme<String>( lightThemes : { 'simple': ThemeData.light( ... ), 'solarized': ThemeData.light( ...), }, darkThemes: { 'simple': ThemeData.dark( ... ), 'solarized': ThemeData.dark( ...), }; themeMode: ThemeMode.system; persistKey: '__theme__', );
//choose the theme theme.state = 'solarized' //toggle between dark and light mode of the chosen them theme.toggle();
-
To internationalize and localize your app:
//US english class EnUS { final helloWorld = 'Hello world'; } //Spanish class EsEs implements EnUs{ final helloWorld = 'Hola Mondo'; }
final i18n = RM.injectI18N<EnUS>( { Local('en', 'US'): ()=> EnUS();//can be async Local('es', 'ES'): ()=> EsES(); }; persistKey: '__lang__', //local persistance of language );
In the UI:
Text(i18n.of(context).helloWorld);
//choose the language i18n.locale = Local('es', 'Es'); //Or choose the system language i18n.locale = SystemLocale();
π See more detailed information about InjectedI18N.
- To set an animation:
final animation = RM.injectAnimation( duration: const Duration(seconds: 1), curve: Curves.linear, );
In the UI: For Implicit animation
Center( child: On.animation( (animate) => Container( // Animate is a callable class width: animate.call(selected ? 200.0 : 100.0), height: animate(selected ? 100.0 : 200.0, 'height'), color: animate(selected ? Colors.red : Colors.blue), alignment: animate(selected ? Alignment.center : AlignmentDirectional.topCenter), child: const FlutterLogo(size: 75), ), ).listenTo(animation), ),
For explicit animation
On.animation( (animate) => Transform.rotate( angle: animate.formTween( (currentValue) => Tween(begin: 0, end: 2 * 3.14), )!, child: const FlutterLogo(size: 75), ), ).listenTo(animation),
π See more detailed information about
InjectedAnimation
.- To deal with TextFields and Form validation
final email = RM.injectTextEditing(): final password = RM.injectTextEditing( validator: (String? value) { if (value!.length < 6) { return "Password must have at least 6 characters"; } return null; }, ); final form = RM.injectForm( autovalidateMode: AutovalidateMode.disable, autoFocusOnFirstError: true, submit: () async { //This is the default submission logic, //It may be override when calling form.submit( () async { }); //It may contains server validation. await serverError = authRepository.signInWithEmailAndPassword( email: email.text, password: password.text, ); //after server validation if(serverError == 'Invalid-Email'){ email.error = 'Invalid email'; } if(serverError == 'Weak-Password'){ email.error = 'Password must have more the 6 characters'; } }, onSubmitting: () { // called while waiting for form submission, }, onSubmitted: () { // called after form is successfully submitted // For example navigation to user page } );
In the UI:
On.form( () => Column( children: <Widget>[ TextField( focusNode: email.focusNode, controller: email.controller, decoration: InputDecoration( errorText: email.error, ), onSubmitted: (_) { //request the password node password.focusNode.requestFocus(); }, ), TextField( focusNode: password.focusNode, controller: password.controller, decoration: new InputDecoration( errorText: password.error, ), onSubmitted: (_) { //request the submit button node form.submitFocusNode.requestFocus(); }, ), On.submission( onSubmitting: () => CircularProgressIndicator(), child : ElevatedButton( focusNode: form.submitFocusNode, onPressed: (){ form.submit(); }, child: Text('Submit'), ), ).listenTo(form), ], ), ).listenTo(form),
π See more detailed information about
InjectedTextEditing and InjectedForm
.- To work with scrolling list:
final scroll = RM.injectScrolling( initialScrollOffset: 0.0, keepScrollOffset: true, endScrollDelay: 300, onScrolling: (scroll){ if (scroll.hasReachedMinExtent) { print('Scrolling vertical list is in its top position'); } if (scroll.hasReachedMaxExtent) { print('Scrolling vertical list is in its bottom position'); } if (scroll.hasStartedScrolling) { //Called only one time. print('User has just start scrolling'); } } );
In the UI:
ListView( controller: scroll.controller, children: <Widget>[], )
-
To mock it in test:
// You can even mock the mocked implementation model.injectMock(()=> MyMockModel());
Similar to
RM.inject
there are:RM.injectFuture // For Future, RM.injectStream, // For Stream, RM.injectFlavor // For flavor and development environment
And many more features.
Examples: #
Basics: #
Since you are new to states_rebuilder
, this is the right place for you to explore. The order below is tailor-made for you π:
-
Hello world app: Hello world app. It gives you the most important feature simply by say hello world. You will understand the concept of global function injection and how to make a pure dart class reactive. You will see how an injected state can depends on other injected state to be refreshed when the other injected state emits notification.
-
The simplest counter app: Default flutter counter app refactored using
states_rebuilder
. -
Login form validation: Simple form login validation. The basic
Injected
concepts are put into practice to make form validation one of the easiest tasks in the world. The concept of exposed model is explained here. -
CountDown timer. This is a timer that ticks from 60 and down to 0. It can be paused, resumed or restarted.
-
Theming and internationalization. This is a demonstration how to handle theme switching and app internationalization using
RM.injectedTheme
andRM.injectedI18N
. -
CRUD query. This is an example of a backend service fetching data app. The app performs CRUD operation using
RM.injectCRUD
. -
Infinite scroll listView. This is another example of CRUD operation using
RM.injectCRUD
. More items will be fetched when the list reaches its bottom.
Advanced: #
Here, you will take your programming skills up a notch, deep dive in Architecture π§:
- User posts and comments: The app communicates with the JSONPlaceholder API, gets a User profile from the login using the ID entered. Fetches and shows the Posts on the home view and shows post details with an additional fetch to show the comments.
Firebase Series: #
- Firebase login The app uses firebase for sign in. The user can sign in anonymously, with google account, with apple account or with email and password.
Firestore Series in Todo App: #
TODOS MVC app The same examples as above adding the possibility for a user to sin up and log in. A user will only see their own todos. The log in will be made with a token which, once expired, the user will be automatically disconnected.
--> -->Note that all of the above examples are tested. With `states_rebuilder`, testing your business logic is the simplest part of your coding time as it is made up of simple dart classes. On the other hand, testing widgets is no less easy, because with `states_rebuilder` you can isolate the widget under test and mock its dependencies.**