diff --git a/states_rebuilder/README.md b/states_rebuilder/README.md index 7b65b4b4..3dc85ec4 100644 --- a/states_rebuilder/README.md +++ b/states_rebuilder/README.md @@ -46,37 +46,88 @@ Contains service application use cases business logic. It defines a set of API t 1. With states_rebuilder you can achieve a clear separation between UI and business logic; 2. Your business logic is made up of pure dart classes without the need to refer to external packages or frameworks (NO extension, NO notification, NO annotation); -```dart -class Foo { - //Vanilla dart class - //NO inheritance form external libraries - //NO notification - //No annotation -} -``` -3. You make a singleton of your logical class available to the widget tree by injecting it using the Injector widget. -```dart -Injector( - inject : [Inject(()=>Foo())] - builder : (context) => MyChildWidget() -) -``` -Injector is a StatefulWidget. It can be used any where in the widget tree. -4. From any child widget of the Injector widget, you can get the registered raw singleton using the static method `Injector.get()` method; -```dart -final Foo foo = Injector.get(); -``` -5. To get the registered singleton wrapped with a reactive environment, you use the static method -`Injector.getAsReactive()` method: -```dart -final ReactiveModel foo = Injector.getAsReactive(); -``` -In fact, for each injected model, states_rebuilder registers two singletons: -- The raw singleton of the model -- The reactive singleton of the model which is the raw singleton wrapped with a reactive environment: -The reactive environment adds getters, fields, and methods to modify the state, track the state of the reactive environment and notify the widgets which are subscribed to it. -6. To subscribe a widget as observer, we use `StateBuilder` widget or define the context parameter in `Injector.getAsReactive(context:context)`. -7. The `setState` method is where actions that mutate the state and send notifications are defined. -What happens is that from the user interface, we use the `setState` method to mutate the state and notify subscribed widgets after the state mutation. In the `setState`, we can define a callback for all the side effects to be executed after the state change and just before rebuilding subscribed widgets using `onSetState`, `onData` and `onError` parameter(or `onRebuild` so that the code executes after the reconstruction). From inside `onSetState`, we can call another `setState` to mutate the state and notify the user interface with another call `onSetState` (`onRebuild`) and so on … - -For more information and tutorials on how states_rebuilder work please check out the [official documentation](https://github.com/GIfatahTH/states_rebuilder). \ No newline at end of file +3. With states_rebuilder you can manage immutable as well as mutable state. + +In this demo implementation, I choose to use immutable state, you can find the same app implemented with mutable state in the example folder of the [official repo in github](https://github.com/GIfatahTH/states_rebuilder). + +In this implementation, I add the requirement that when a todo is added, deleted or updated, it will be instantly displayed in the user interface, so that the user will not notice any delay, and the async method `saveTodo` will be called in the background to persist the change. If the `saveTodo` method fails, the old state is returned and displayed back with a` SnackBar` containing the error message. + +The idea is simple: +1- Write your immutable `TodosState` class using pure dart without any use of external libraries. + ```dart + @immutable + class TodosState { + TodosState({ + ITodosRepository todoRepository, + List todos, + VisibilityFilter activeFilter, + }) : _todoRepository = todoRepository, + _todos = todos, + _activeFilter = activeFilter; + + //.... + } + ``` +2- Inject the `TodosState` using the `Injector` widget, + ```dart + return Injector( + inject: [ + Inject( + () => TodosState( + todos: [], + activeFilter: VisibilityFilter.all, + todoRepository: repository, + ), + ) + ], + ``` +3- use one of the available observer widgets offered by states_rebuilder to subscribed to the `TodosState` `ReactiveModel`. + ```dart + return StateBuilder( + observe: () => RM.get(), + builder: (context, todosStoreRM) { + //... + + } + ) + ``` +4- to notify the observing widgets use: + * for sync method: use `setValue` method or the `value` setter. + ```dart + onSelected: (filter) { + + activeFilterRM.setValue( + () => filter, + onData: (context, data) { + RM.get().value = + RM.get().value.copyWith(activeFilter: filter); + }, + ); + ``` + * for async future method: use future method. + ```dart + body: WhenRebuilderOr( + observeMany: [ + () => RM.get().asNew(HomeScreen) + ..future((t) => t.loadTodos()) + .onError(ErrorHandler.showErrorDialog), + () => _activeTabRMKey, + ] + + //... + ) + ``` + * for async stream method: use stream method. + ```dart + onSelected: (action) { + + RM.get() + .stream( + (action == ExtraAction.toggleAllComplete) + ? (t) => t.toggleAll() + : (t) => t.clearCompleted(), + ) + .onError(ErrorHandler.showErrorSnackBar); + } + ``` + diff --git a/states_rebuilder/android/settings_aar.gradle b/states_rebuilder/android/settings_aar.gradle new file mode 100644 index 00000000..e7b4def4 --- /dev/null +++ b/states_rebuilder/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/states_rebuilder/lib/app.dart b/states_rebuilder/lib/app.dart index 83ea8fcb..946cb5c1 100644 --- a/states_rebuilder/lib/app.dart +++ b/states_rebuilder/lib/app.dart @@ -1,23 +1,38 @@ import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; import 'package:todos_app_core/todos_app_core.dart'; -import 'data_source/todo_repository.dart'; + import 'localization.dart'; -import 'service/todos_service.dart'; +import 'service/common/enums.dart'; +import 'service/interfaces/i_todo_repository.dart'; +import 'service/todos_state.dart'; import 'ui/pages/add_edit_screen.dart/add_edit_screen.dart'; import 'ui/pages/home_screen/home_screen.dart'; class StatesRebuilderApp extends StatelessWidget { - final StatesBuilderTodosRepository repository; + final ITodosRepository repository; const StatesRebuilderApp({Key key, this.repository}) : super(key: key); @override Widget build(BuildContext context) { - //Injecting the TodoService globally before MaterialApp widget. + ////uncomment this line to consol log and see the notification timeline + //RM.printActiveRM = true; + + // + //Injecting the TodosState globally before MaterialApp widget. //It will be available throughout all the widget tree even after navigation. + //The initial state is an empty todos and VisibilityFilter.all return Injector( - inject: [Inject(() => TodosService(repository))], + inject: [ + Inject( + () => TodosState( + todos: [], + activeFilter: VisibilityFilter.all, + todoRepository: repository, + ), + ) + ], builder: (_) => MaterialApp( title: StatesRebuilderLocalizations().appTitle, theme: ArchSampleTheme.theme, diff --git a/states_rebuilder/lib/data_source/todo_repository.dart b/states_rebuilder/lib/data_source/todo_repository.dart index d215055e..591630d8 100644 --- a/states_rebuilder/lib/data_source/todo_repository.dart +++ b/states_rebuilder/lib/data_source/todo_repository.dart @@ -4,10 +4,10 @@ import '../domain/entities/todo.dart'; import '../service/exceptions/persistance_exception.dart'; import '../service/interfaces/i_todo_repository.dart'; -class StatesBuilderTodosRepository implements ITodosRepository { +class StatesRebuilderTodosRepository implements ITodosRepository { final core.TodosRepository _todosRepository; - StatesBuilderTodosRepository({core.TodosRepository todosRepository}) + StatesRebuilderTodosRepository({core.TodosRepository todosRepository}) : _todosRepository = todosRepository; @override @@ -27,16 +27,18 @@ class StatesBuilderTodosRepository implements ITodosRepository { } @override - Future saveTodos(List todos) { + Future saveTodos(List todos) async { try { var todosEntities = []; + //// to simulate en error uncomment these lines. + // await Future.delayed(Duration(milliseconds: 500)); + // throw Exception(); for (var todo in todos) { todosEntities.add(TodoEntity.fromJson(todo.toJson())); } - return _todosRepository.saveTodos(todosEntities); } catch (e) { - throw PersistanceException('There is a problem in saving todos : $e'); + throw PersistanceException('There is a problem in saving todos'); } } } diff --git a/states_rebuilder/lib/domain/entities/todo.dart b/states_rebuilder/lib/domain/entities/todo.dart index c149058b..e493ae70 100644 --- a/states_rebuilder/lib/domain/entities/todo.dart +++ b/states_rebuilder/lib/domain/entities/todo.dart @@ -1,23 +1,25 @@ +import 'package:flutter/cupertino.dart'; import 'package:todos_app_core/todos_app_core.dart' as flutter_arch_sample_app; import '../exceptions/validation_exception.dart'; -//Entity is a mutable object with an ID. It should contain all the logic It controls. -//Entity is validated just before persistance, ie, in toMap() method. +@immutable class Todo { - String id; - bool complete; - String note; - String task; + final String id; + final bool complete; + final String note; + final String task; Todo(this.task, {String id, this.note, this.complete = false}) : id = id ?? flutter_arch_sample_app.Uuid().generateV4(); - Todo.fromJson(Map map) { - id = map['id'] as String; - task = map['task'] as String; - note = map['note'] as String; - complete = map['complete'] as bool; + factory Todo.fromJson(Map map) { + return Todo( + map['task'] as String, + id: map['id'] as String, + note: map['note'] as String, + complete: map['complete'] as bool, + ); } // toJson is called just before persistance. @@ -41,11 +43,38 @@ class Todo { } } + Todo copyWith({ + String task, + String note, + bool complete, + String id, + }) { + return Todo( + task ?? this.task, + id: id ?? this.id, + note: note ?? this.note, + complete: complete ?? this.complete, + ); + } + + @override + bool operator ==(Object o) { + if (identical(this, o)) return true; + + return o is Todo && + o.id == id && + o.complete == complete && + o.note == note && + o.task == task; + } + @override - int get hashCode => id.hashCode; + int get hashCode { + return id.hashCode ^ complete.hashCode ^ note.hashCode ^ task.hashCode; + } @override - bool operator ==(Object other) => - identical(this, other) || - other is Todo && runtimeType == other.runtimeType && id == other.id; + String toString() { + return 'Todo(id: $id,task:$task, complete: $complete)'; + } } diff --git a/states_rebuilder/lib/domain/exceptions/validation_exception.dart b/states_rebuilder/lib/domain/exceptions/validation_exception.dart index e348fcd7..6f60b379 100644 --- a/states_rebuilder/lib/domain/exceptions/validation_exception.dart +++ b/states_rebuilder/lib/domain/exceptions/validation_exception.dart @@ -2,4 +2,8 @@ class ValidationException extends Error { final String message; ValidationException(this.message); + @override + String toString() { + return message; + } } diff --git a/states_rebuilder/lib/main.dart b/states_rebuilder/lib/main.dart index 63784241..8a16c84e 100644 --- a/states_rebuilder/lib/main.dart +++ b/states_rebuilder/lib/main.dart @@ -12,10 +12,9 @@ import 'data_source/todo_repository.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - runApp( StatesRebuilderApp( - repository: StatesBuilderTodosRepository( + repository: StatesRebuilderTodosRepository( todosRepository: LocalStorageRepository( localStorage: KeyValueStorage( 'states_rebuilder', diff --git a/states_rebuilder/lib/main_web.dart b/states_rebuilder/lib/main_web.dart index 97eae9a0..3d5cf765 100644 --- a/states_rebuilder/lib/main_web.dart +++ b/states_rebuilder/lib/main_web.dart @@ -15,7 +15,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); runApp( StatesRebuilderApp( - repository: StatesBuilderTodosRepository( + repository: StatesRebuilderTodosRepository( todosRepository: LocalStorageRepository( localStorage: KeyValueStorage( 'states_rebuilder', diff --git a/states_rebuilder/lib/service/exceptions/persistance_exception.dart b/states_rebuilder/lib/service/exceptions/persistance_exception.dart index fdc86d9e..d5a76c8d 100644 --- a/states_rebuilder/lib/service/exceptions/persistance_exception.dart +++ b/states_rebuilder/lib/service/exceptions/persistance_exception.dart @@ -1,5 +1,8 @@ class PersistanceException extends Error { final String message; - PersistanceException(this.message); + @override + String toString() { + return message.toString(); + } } diff --git a/states_rebuilder/lib/service/todos_service.dart b/states_rebuilder/lib/service/todos_service.dart deleted file mode 100644 index 49d80081..00000000 --- a/states_rebuilder/lib/service/todos_service.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:states_rebuilder_sample/domain/entities/todo.dart'; - -import 'common/enums.dart'; -import 'interfaces/i_todo_repository.dart'; - -//`TodosService` is a pure dart class that can be easily tested (see test folder). - -class TodosService { - //Constructor injection of the ITodoRepository abstract class. - TodosService(ITodosRepository todoRepository) - : _todoRepository = todoRepository; - - //private fields - final ITodosRepository _todoRepository; - List _todos = const []; - - //public field - VisibilityFilter activeFilter = VisibilityFilter.all; - - //getters - List get todos { - if (activeFilter == VisibilityFilter.active) { - return _activeTodos; - } - if (activeFilter == VisibilityFilter.completed) { - return _completedTodos; - } - return _todos; - } - - List get _completedTodos => _todos.where((t) => t.complete).toList(); - List get _activeTodos => _todos.where((t) => !t.complete).toList(); - int get numCompleted => _completedTodos.length; - int get numActive => _activeTodos.length; - bool get allComplete => _activeTodos.isEmpty; - - //methods for CRUD - void loadTodos() async { - _todos = await _todoRepository.loadTodos(); - } - - void addTodo(Todo todo) { - _todos.add(todo); - _todoRepository.saveTodos(_todos); - } - - void updateTodo(Todo todo) { - final index = _todos.indexOf(todo); - if (index == -1) return; - _todos[index] = todo; - _todoRepository.saveTodos(_todos); - } - - void deleteTodo(Todo todo) { - if (_todos.remove(todo)) { - _todoRepository.saveTodos(_todos); - } - } - - void toggleAll() { - final allComplete = _todos.every((todo) => todo.complete); - - for (final todo in _todos) { - todo.complete = !allComplete; - } - _todoRepository.saveTodos(_todos); - } - - void clearCompleted() { - _todos.removeWhere((todo) => todo.complete); - _todoRepository.saveTodos(_todos); - } -} diff --git a/states_rebuilder/lib/service/todos_state.dart b/states_rebuilder/lib/service/todos_state.dart new file mode 100644 index 00000000..4208720d --- /dev/null +++ b/states_rebuilder/lib/service/todos_state.dart @@ -0,0 +1,147 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:states_rebuilder_sample/domain/entities/todo.dart'; + +import 'common/enums.dart'; +import 'interfaces/i_todo_repository.dart'; + +//`TodosState` is a pure dart immutable class that can be easily tested (see test folder). +@immutable +class TodosState { + //Constructor injection of the ITodoRepository abstract class. + TodosState({ + ITodosRepository todoRepository, + List todos, + VisibilityFilter activeFilter, + }) : _todoRepository = todoRepository, + _todos = todos, + _activeFilter = activeFilter; + + //private fields + final ITodosRepository _todoRepository; + final List _todos; + final VisibilityFilter _activeFilter; + + //public getters + List get todos { + if (_activeFilter == VisibilityFilter.active) { + return _activeTodos; + } + if (_activeFilter == VisibilityFilter.completed) { + return _completedTodos; + } + return _todos; + } + + int get numCompleted => _completedTodos.length; + int get numActive => _activeTodos.length; + bool get allComplete => _activeTodos.isEmpty; + //private getter + List get _completedTodos => _todos.where((t) => t.complete).toList(); + List get _activeTodos => _todos.where((t) => !t.complete).toList(); + + //methods for CRUD + + //When we want to await for the future and display something in the screen, + //we use future. + static Future loadTodos(TodosState todosState) async { + ////If you want to simulate loading failure uncomment theses lines + // await Future.delayed(Duration(seconds: 5)); + // throw PersistanceException('net work error'); + final _todos = await todosState._todoRepository.loadTodos(); + return todosState.copyWith( + todos: _todos, + activeFilter: VisibilityFilter.all, + ); + } + + //We use stream generator when we want to instantly display the update, and execute the the saveTodos in the background, + //and if the saveTodos fails we want to display the old state and a snackbar containing the error message + // + //Notice that this method is static pure function, it is already isolated to be tested easily + static Stream addTodo(TodosState todosState, Todo todo) async* { + final newTodos = List.from(todosState._todos)..add(todo); + + yield* _saveTodos(todosState, newTodos); + } + + static Stream updateTodo( + TodosState todosState, Todo todo) async* { + final newTodos = + todosState._todos.map((t) => t.id == todo.id ? todo : t).toList(); + yield* _saveTodos(todosState, newTodos); + } + + static Stream deleteTodo( + TodosState todosState, Todo todo) async* { + final newTodos = List.from(todosState._todos)..remove(todo); + yield* _saveTodos(todosState, newTodos); + } + + static Stream toggleAll(TodosState todosState) async* { + final newTodos = todosState._todos + .map( + (t) => t.copyWith(complete: !todosState.allComplete), + ) + .toList(); + yield* _saveTodos(todosState, newTodos); + } + + static Stream clearCompleted(TodosState todosState) async* { + final newTodos = List.from(todosState._todos) + ..removeWhere( + (t) => t.complete, + ); + yield* _saveTodos(todosState, newTodos); + } + + static Stream _saveTodos( + TodosState todosState, + List newTodos, + ) async* { + //Yield the new state, and states_rebuilder will rebuild observer widgets + yield todosState.copyWith( + todos: newTodos, + ); + try { + await todosState._todoRepository.saveTodos(newTodos); + } catch (e) { + //on error yield the old state, states_rebuilder will rebuild the UI to display the old state + yield todosState; + //rethrow the error so that states_rebuilder can display the snackbar containing the error message + rethrow; + } + } + + TodosState copyWith({ + ITodosRepository todoRepository, + List todos, + VisibilityFilter activeFilter, + }) { + final filter = todos?.isEmpty == true ? VisibilityFilter.all : activeFilter; + return TodosState( + todoRepository: todoRepository ?? _todoRepository, + todos: todos ?? _todos, + activeFilter: filter ?? _activeFilter, + ); + } + + @override + String toString() => + 'TodosState(_todoRepository: $_todoRepository, _todos: $_todos, activeFilter: $_activeFilter)'; + + @override + bool operator ==(Object o) { + if (identical(this, o)) return true; + + return o is TodosState && + o._todoRepository == _todoRepository && + listEquals(o._todos, _todos) && + o._activeFilter == _activeFilter; + } + + @override + int get hashCode => + _todoRepository.hashCode ^ _todos.hashCode ^ _activeFilter.hashCode; +} diff --git a/states_rebuilder/lib/ui/common/helper_methods.dart b/states_rebuilder/lib/ui/common/helper_methods.dart deleted file mode 100644 index eba8136c..00000000 --- a/states_rebuilder/lib/ui/common/helper_methods.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:states_rebuilder/states_rebuilder.dart'; -import 'package:todos_app_core/todos_app_core.dart'; - -import '../../domain/entities/todo.dart'; -import '../../service/todos_service.dart'; - -class HelperMethods { - static void removeTodo(Todo todo) { - final todosServiceRM = Injector.getAsReactive(); - todosServiceRM.setState( - (s) => s.deleteTodo(todo), - onSetState: (context) { - Scaffold.of(context).showSnackBar( - SnackBar( - key: ArchSampleKeys.snackbar, - duration: Duration(seconds: 2), - content: Text( - ArchSampleLocalizations.of(context).todoDeleted(todo.task), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - action: SnackBarAction( - label: ArchSampleLocalizations.of(context).undo, - onPressed: () { - todosServiceRM.setState((s) => s.addTodo(todo)); - }, - ), - ), - ); - }, - ); - } -} diff --git a/states_rebuilder/lib/ui/exceptions/error_handler.dart b/states_rebuilder/lib/ui/exceptions/error_handler.dart index 4be1dc88..39c260d4 100644 --- a/states_rebuilder/lib/ui/exceptions/error_handler.dart +++ b/states_rebuilder/lib/ui/exceptions/error_handler.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:states_rebuilder_sample/domain/exceptions/validation_exception.dart'; import 'package:states_rebuilder_sample/service/exceptions/persistance_exception.dart'; @@ -13,4 +14,42 @@ class ErrorHandler { throw (error); } + + static void showErrorSnackBar(BuildContext context, dynamic error) { + Scaffold.of(context).hideCurrentSnackBar(); + Scaffold.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Text(ErrorHandler.getErrorMessage(error)), + Spacer(), + Icon( + Icons.error_outline, + color: Colors.yellow, + ) + ], + ), + ), + ); + } + + static void showErrorDialog(BuildContext context, dynamic error) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Colors.yellow, + ), + Text(ErrorHandler.getErrorMessage(error)), + ], + ), + ); + }, + ); + } } diff --git a/states_rebuilder/lib/ui/pages/add_edit_screen.dart/add_edit_screen.dart b/states_rebuilder/lib/ui/pages/add_edit_screen.dart/add_edit_screen.dart index c3089261..0c558d28 100644 --- a/states_rebuilder/lib/ui/pages/add_edit_screen.dart/add_edit_screen.dart +++ b/states_rebuilder/lib/ui/pages/add_edit_screen.dart/add_edit_screen.dart @@ -8,7 +8,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; import 'package:states_rebuilder_sample/domain/entities/todo.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; +import 'package:states_rebuilder_sample/service/todos_state.dart'; +import 'package:states_rebuilder_sample/ui/exceptions/error_handler.dart'; import 'package:todos_app_core/todos_app_core.dart'; class AddEditPage extends StatefulWidget { @@ -29,7 +30,6 @@ class _AddEditPageState extends State { String _task; String _note; bool get isEditing => widget.todo != null; - final todosService = Injector.get(); @override Widget build(BuildContext context) { return Scaffold( @@ -85,22 +85,20 @@ class _AddEditPageState extends State { final form = formKey.currentState; if (form.validate()) { form.save(); - if (isEditing) { - widget.todo - ..task = _task - ..note = _note; - todosService.updateTodo(widget.todo); - } else { - todosService.addTodo( - Todo( - _task, - note: _note, - ), + final newTodo = widget.todo.copyWith( + task: _task, + note: _note, ); - } + Navigator.pop(context, newTodo); + } else { + Navigator.pop(context); - Navigator.pop(context); + RM.get().setState( + (t) => TodosState.addTodo(t, Todo(_task, note: _note)), + onError: ErrorHandler.showErrorSnackBar, + ); + } } }, ), diff --git a/states_rebuilder/lib/ui/pages/detail_screen/detail_screen.dart b/states_rebuilder/lib/ui/pages/detail_screen/detail_screen.dart index ea3f244b..79e3daff 100644 --- a/states_rebuilder/lib/ui/pages/detail_screen/detail_screen.dart +++ b/states_rebuilder/lib/ui/pages/detail_screen/detail_screen.dart @@ -5,16 +5,16 @@ import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; import 'package:states_rebuilder_sample/domain/entities/todo.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; -import 'package:states_rebuilder_sample/ui/common/helper_methods.dart'; +import 'package:states_rebuilder_sample/service/todos_state.dart'; +import 'package:states_rebuilder_sample/ui/exceptions/error_handler.dart'; import 'package:states_rebuilder_sample/ui/pages/add_edit_screen.dart/add_edit_screen.dart'; import 'package:todos_app_core/todos_app_core.dart'; class DetailScreen extends StatelessWidget { DetailScreen(this.todo) : super(key: ArchSampleKeys.todoDetailsScreen); final Todo todo; - //use Injector.get because DetailScreen need not be reactive - final todosService = Injector.get(); + final todoRMKey = RMKey(); + @override Widget build(BuildContext context) { return Scaffold( @@ -26,14 +26,7 @@ class DetailScreen extends StatelessWidget { tooltip: ArchSampleLocalizations.of(context).deleteTodo, icon: Icon(Icons.delete), onPressed: () { - //This is one particularity of states_rebuilder - //We have the ability to call a method form an injected model without notify observers - //This can be done by consuming the injected model using Injector.get and call the method we want. - todosService.deleteTodo(todo); - //When navigating back to home page, rebuild is granted by flutter framework. - Navigator.pop(context, todo); - //delegate to the static method HelperMethods.removeTodo to remove todo - HelperMethods.removeTodo(todo); + Navigator.pop(context, true); }, ) ], @@ -42,71 +35,91 @@ class DetailScreen extends StatelessWidget { padding: EdgeInsets.all(16.0), child: ListView( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(right: 8.0), - child: StateBuilder( - //getting a new ReactiveModel of TodosService to optimize rebuild of widgets - builder: (_, todosServiceRM) { - return Checkbox( - value: todo.complete, - key: ArchSampleKeys.detailsTodoItemCheckbox, - onChanged: (complete) { - todo.complete = !todo.complete; - //only this checkBox will rebuild - todosServiceRM.setState((s) => s.updateTodo(todo)); - }, - ); - }, - ), - ), - Expanded( - child: Column( + StateBuilder( + //create a local ReactiveModel for the todo + observe: () => RM.create(todo), + //associate ti with todoRMKey + rmKey: todoRMKey, + builder: (context, todosStateRM) { + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.only( - top: 8.0, - bottom: 16.0, - ), - child: Text( - todo.task, - key: ArchSampleKeys.detailsTodoItemTask, - style: Theme.of(context).textTheme.headline, + padding: EdgeInsets.only(right: 8.0), + child: Checkbox( + key: ArchSampleKeys.detailsTodoItemCheckbox, + value: todosStateRM.state.complete, + onChanged: (value) { + final newTodo = todosStateRM.state.copyWith( + complete: value, + ); + _updateTodo(context, newTodo); + }, + )), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + top: 8.0, + bottom: 16.0, + ), + child: Text( + todosStateRM.state.task, + key: ArchSampleKeys.detailsTodoItemTask, + style: Theme.of(context).textTheme.headline, + ), + ), + Text( + todosStateRM.state.note, + key: ArchSampleKeys.detailsTodoItemNote, + style: Theme.of(context).textTheme.subhead, + ) + ], ), ), - Text( - todo.note, - key: ArchSampleKeys.detailsTodoItemNote, - style: Theme.of(context).textTheme.subhead, - ) ], - ), - ), - ], - ), + ); + }), ], ), ), - floatingActionButton: FloatingActionButton( - tooltip: ArchSampleLocalizations.of(context).editTodo, - child: Icon(Icons.edit), - key: ArchSampleKeys.editTodoFab, - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return AddEditPage( - key: ArchSampleKeys.editTodoScreen, - todo: todo, - ); - }, - ), + floatingActionButton: Builder( + builder: (context) { + return FloatingActionButton( + tooltip: ArchSampleLocalizations.of(context).editTodo, + child: Icon(Icons.edit), + key: ArchSampleKeys.editTodoFab, + onPressed: () async { + final newTodo = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return AddEditPage( + key: ArchSampleKeys.editTodoScreen, + todo: todoRMKey.state, + ); + }, + ), + ); + if (newTodo == null) { + return; + } + _updateTodo(context, newTodo); + }, ); }, ), ); } + + void _updateTodo(BuildContext context, Todo newTodo) { + final oldTodo = todoRMKey.state; + todoRMKey.state = newTodo; + RM.get().setState((t) => TodosState.updateTodo(t, newTodo), + onError: (ctx, error) { + todoRMKey.state = oldTodo; + ErrorHandler.showErrorSnackBar(context, error); + }); + } } diff --git a/states_rebuilder/lib/ui/pages/home_screen/extra_actions_button.dart b/states_rebuilder/lib/ui/pages/home_screen/extra_actions_button.dart index aca10542..7e757545 100644 --- a/states_rebuilder/lib/ui/pages/home_screen/extra_actions_button.dart +++ b/states_rebuilder/lib/ui/pages/home_screen/extra_actions_button.dart @@ -1,40 +1,53 @@ import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; -import 'package:states_rebuilder_sample/ui/common/enums.dart'; import 'package:todos_app_core/todos_app_core.dart'; +import '../../../service/todos_state.dart'; +import '../../common/enums.dart'; +import '../../exceptions/error_handler.dart'; + class ExtraActionsButton extends StatelessWidget { ExtraActionsButton({Key key}) : super(key: key); - final todosServiceRM = Injector.getAsReactive(); @override Widget build(BuildContext context) { - return PopupMenuButton( - key: ArchSampleKeys.extraActionsButton, - onSelected: (action) { - if (action == ExtraAction.toggleAllComplete) { - todosServiceRM.setState((s) => s.toggleAll()); - } else if (action == ExtraAction.clearCompleted) { - todosServiceRM.setState((s) => s.clearCompleted()); - } - }, - itemBuilder: (BuildContext context) { - return >[ - PopupMenuItem( - key: ArchSampleKeys.toggleAll, - value: ExtraAction.toggleAllComplete, - child: Text(todosServiceRM.state.allComplete - ? ArchSampleLocalizations.of(context).markAllIncomplete - : ArchSampleLocalizations.of(context).markAllComplete), - ), - PopupMenuItem( - key: ArchSampleKeys.clearCompleted, - value: ExtraAction.clearCompleted, - child: Text(ArchSampleLocalizations.of(context).clearCompleted), - ), - ]; - }, - ); + //This is an example of local ReactiveModel + return StateBuilder( + //Create a reactiveModel of type ExtraAction and set its initialValue to ExtraAction.clearCompleted) + observe: () => RM.create(ExtraAction.clearCompleted), + builder: (context, extraActionRM) { + return PopupMenuButton( + key: ArchSampleKeys.extraActionsButton, + onSelected: (action) { + //first set the state to the new action + //See FilterButton where we use setState there. + extraActionRM.state = action; + + RM.get().setState( + (action == ExtraAction.toggleAllComplete) + ? (t) => TodosState.toggleAll(t) + : (t) => TodosState.clearCompleted(t), + onError: ErrorHandler.showErrorSnackBar, + ); + }, + itemBuilder: (BuildContext context) { + return >[ + PopupMenuItem( + key: ArchSampleKeys.toggleAll, + value: ExtraAction.toggleAllComplete, + child: Text(IN.get().allComplete + ? ArchSampleLocalizations.of(context).markAllIncomplete + : ArchSampleLocalizations.of(context).markAllComplete), + ), + PopupMenuItem( + key: ArchSampleKeys.clearCompleted, + value: ExtraAction.clearCompleted, + child: + Text(ArchSampleLocalizations.of(context).clearCompleted), + ), + ]; + }, + ); + }); } } diff --git a/states_rebuilder/lib/ui/pages/home_screen/filter_button.dart b/states_rebuilder/lib/ui/pages/home_screen/filter_button.dart index 0687f5af..f9586d9b 100644 --- a/states_rebuilder/lib/ui/pages/home_screen/filter_button.dart +++ b/states_rebuilder/lib/ui/pages/home_screen/filter_button.dart @@ -5,95 +5,119 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; -import 'package:states_rebuilder_sample/service/common/enums.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; import 'package:todos_app_core/todos_app_core.dart'; -class FilterButton extends StatelessWidget { - const FilterButton({this.isActive, Key key}) : super(key: key); - final bool isActive; +import '../../../service/common/enums.dart'; +import '../../../service/todos_state.dart'; +import '../../common/enums.dart'; +class FilterButton extends StatelessWidget { + //Accept the activeTabRM defined in the HomePage + const FilterButton({this.activeTabRM, Key key}) : super(key: key); + final ReactiveModel activeTabRM; @override Widget build(BuildContext context) { - //context is used to register FilterButton as observer in todosServiceRM - final todosServiceRM = - Injector.getAsReactive(context: context); - final defaultStyle = Theme.of(context).textTheme.body1; final activeStyle = Theme.of(context) .textTheme .body1 .copyWith(color: Theme.of(context).accentColor); final button = _Button( - onSelected: (filter) { - todosServiceRM.setState((s) => s.activeFilter = filter); - }, - activeFilter: todosServiceRM.state.activeFilter, activeStyle: activeStyle, defaultStyle: defaultStyle, ); - return AnimatedOpacity( - opacity: isActive ? 1.0 : 0.0, - duration: Duration(milliseconds: 150), - child: isActive ? button : IgnorePointer(child: button), - ); + return StateBuilder( + //register to activeTabRM + observe: () => activeTabRM, + builder: (context, activeTabRM) { + final _isActive = activeTabRM.state == AppTab.todos; + return AnimatedOpacity( + opacity: _isActive ? 1.0 : 0.0, + duration: Duration(milliseconds: 150), + child: _isActive ? button : IgnorePointer(child: button), + ); + }); } } class _Button extends StatelessWidget { const _Button({ Key key, - @required this.onSelected, - @required this.activeFilter, @required this.activeStyle, @required this.defaultStyle, }) : super(key: key); - final PopupMenuItemSelected onSelected; - final VisibilityFilter activeFilter; final TextStyle activeStyle; final TextStyle defaultStyle; @override Widget build(BuildContext context) { - return PopupMenuButton( - key: ArchSampleKeys.filterButton, - tooltip: ArchSampleLocalizations.of(context).filterTodos, - onSelected: onSelected, - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - key: ArchSampleKeys.allFilter, - value: VisibilityFilter.all, - child: Text( - ArchSampleLocalizations.of(context).showAll, - style: activeFilter == VisibilityFilter.all - ? activeStyle - : defaultStyle, - ), - ), - PopupMenuItem( - key: ArchSampleKeys.activeFilter, - value: VisibilityFilter.active, - child: Text( - ArchSampleLocalizations.of(context).showActive, - style: activeFilter == VisibilityFilter.active - ? activeStyle - : defaultStyle, - ), - ), - PopupMenuItem( - key: ArchSampleKeys.completedFilter, - value: VisibilityFilter.completed, - child: Text( - ArchSampleLocalizations.of(context).showCompleted, - style: activeFilter == VisibilityFilter.completed - ? activeStyle - : defaultStyle, - ), - ), - ], - icon: Icon(Icons.filter_list), - ); + //This is an example of Local ReactiveModel + return StateBuilder( + //Create and subscribe to a ReactiveModel of type VisibilityFilter + observe: () => RM.create(VisibilityFilter.all), + builder: (context, activeFilterRM) { + return PopupMenuButton( + key: ArchSampleKeys.filterButton, + tooltip: ArchSampleLocalizations.of(context).filterTodos, + onSelected: (filter) { + //Compere this onSelected callBack with that of the ExtraActionsButton widget. + // + //In ExtraActionsButton, we did not use the setState. + //Here we use the setState (although we can use activeFilterRM.state = filter ). + + // + //The reason we use setState is to minimize the rebuild process. + //If the use select the same option, the setState method will not notify observers. + //and onData will not invoked. + activeFilterRM.setState( + (_) => filter, + onData: (_, __) { + //get and set the state of the global ReactiveModel TodosStore + RM.get().setState( + (currentSate) => currentSate.copyWith( + activeFilter: filter, + ), + ); + }, + ); + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + key: ArchSampleKeys.allFilter, + value: VisibilityFilter.all, + child: Text( + ArchSampleLocalizations.of(context).showAll, + style: activeFilterRM.state == VisibilityFilter.all + ? activeStyle + : defaultStyle, + ), + ), + PopupMenuItem( + key: ArchSampleKeys.activeFilter, + value: VisibilityFilter.active, + child: Text( + ArchSampleLocalizations.of(context).showActive, + style: activeFilterRM.state == VisibilityFilter.active + ? activeStyle + : defaultStyle, + ), + ), + PopupMenuItem( + key: ArchSampleKeys.completedFilter, + value: VisibilityFilter.completed, + child: Text( + ArchSampleLocalizations.of(context).showCompleted, + style: activeFilterRM.state == VisibilityFilter.completed + ? activeStyle + : defaultStyle, + ), + ), + ], + icon: Icon(Icons.filter_list), + ); + }); } } diff --git a/states_rebuilder/lib/ui/pages/home_screen/home_screen.dart b/states_rebuilder/lib/ui/pages/home_screen/home_screen.dart index 69655b82..95ccf065 100644 --- a/states_rebuilder/lib/ui/pages/home_screen/home_screen.dart +++ b/states_rebuilder/lib/ui/pages/home_screen/home_screen.dart @@ -3,42 +3,65 @@ import 'package:states_rebuilder/states_rebuilder.dart'; import 'package:todos_app_core/todos_app_core.dart'; import '../../../localization.dart'; -import '../../../service/todos_service.dart'; +import '../../../service/todos_state.dart'; import '../../common/enums.dart'; +import '../../exceptions/error_handler.dart'; import 'extra_actions_button.dart'; import 'filter_button.dart'; import 'stats_counter.dart'; import 'todo_list.dart'; -class HomeScreen extends StatefulWidget { - HomeScreen({Key key}) : super(key: key ?? ArchSampleKeys.homeScreen); - - @override - _HomeScreenState createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - // Here we use a StatefulWidget to store the _activeTab state which is private to this class - - AppTab _activeTab = AppTab.todos; +//states_rebuilder is based on the concept fo ReactiveModels. +//ReactiveModels can be local or global. +class HomeScreen extends StatelessWidget { + //Create a reactive model key to handle app tab navigation. + //ReactiveModel keys are used for local ReactiveModels (similar to Flutter global key) + final _activeTabRMKey = RMKey(AppTab.todos); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(StatesRebuilderLocalizations.of(context).appTitle), actions: [ - FilterButton(isActive: _activeTab == AppTab.todos), + //As FilterButton should respond to the active tab, the activeTab reactiveModel is + //injected throw the constructor using the ReactiveModels key, + FilterButton(activeTabRM: _activeTabRMKey), ExtraActionsButton(), ], ), - body: StateBuilder( - models: [Injector.getAsReactive()], - initState: (_, todosServiceRM) { - //update state and notify observer - return todosServiceRM.setState((s) => s.loadTodos()); + //WhenRebuilderOr is one of four widget used by states_rebuilder to subscribe to observable ReactiveModels + body: WhenRebuilderOr( + //subscribe this widget to many observables. + //This widget will rebuild when the loadTodos future method resolves and, + //when the state of the active AppTab is changed + observeMany: [ + //Here get a new reactiveModel of the injected TodosStore + //we use the HomeScreen seed so that if other pages emits a notification this widget will not be notified + () => RM.get().asNew(HomeScreen) + //using the cascade operator, we call the todosLoad method informing states_rebuilder that is is a future + ..setState( + (t) => TodosState.loadTodos(t), + //Invoke the error callBack to handle the error + onError: ErrorHandler.showErrorDialog, + ), + //Her we subscribe to the activeTab ReactiveModel key + () => _activeTabRMKey, + ], + //When any of the observed model is waiting for a future to resolve or stream to begin, + //this onWaiting method is called, + onWaiting: () { + return Center( + child: CircularProgressIndicator( + key: ArchSampleKeys.todosLoading, + ), + ); }, - builder: (_, todosServiceRM) { - return _activeTab == AppTab.todos ? TodoList() : StatsCounter(); + //WhenRebuilderOr has other optional callBacks (onData, onIdle, onError). + //the builder is the default one. + builder: (context, _activeTabRM) { + return _activeTabRM.state == AppTab.todos + ? TodoList() + : StatsCounter(); }, ), floatingActionButton: FloatingActionButton( @@ -49,31 +72,46 @@ class _HomeScreenState extends State { child: Icon(Icons.add), tooltip: ArchSampleLocalizations.of(context).addTodo, ), - bottomNavigationBar: BottomNavigationBar( - key: ArchSampleKeys.tabs, - currentIndex: AppTab.values.indexOf(_activeTab), - onTap: (index) { - //mutate the state of the private field _activeTab and use Flutter setState because - setState(() => _activeTab = AppTab.values[index]); - }, - items: AppTab.values.map( - (tab) { - return BottomNavigationBarItem( - icon: Icon( - tab == AppTab.todos ? Icons.list : Icons.show_chart, - key: tab == AppTab.stats - ? ArchSampleKeys.statsTab - : ArchSampleKeys.todoTab, - ), - title: Text( - tab == AppTab.stats - ? ArchSampleLocalizations.of(context).stats - : ArchSampleLocalizations.of(context).todos, - ), + //StateBuilder is the second of three widget used to subscribe to observables + bottomNavigationBar: StateBuilder( + //Here we create a local ReactiveModel of type AppTab with default state of AppTab.todos) + observe: () => RM.create(AppTab.todos), + //To control or use the value of this local ReactiveModel outside this Widget, + //we use key in the same fashion Flutter gobble key is used. + //Here we associated the already defined ReactiveModel key with this widget. + rmKey: _activeTabRMKey, + //The builder method exposes the BuildContext and the ReactiveModel model of type defined in + // the generic type of the StateBuilder + builder: (context, _activeTabRM) { + return BottomNavigationBar( + key: ArchSampleKeys.tabs, + currentIndex: AppTab.values.indexOf(_activeTabRM.state), + onTap: (index) { + //mutate the state of the private field _activeTabRM, + //observing widget will be notified to rebuild + //We have three observing widgets : this StateBuilder, the WhenRebuilderOr, + //ond the StateBuilder defined in the FilterButton widget + _activeTabRM.state = AppTab.values[index]; + }, + items: AppTab.values.map( + (tab) { + return BottomNavigationBarItem( + icon: Icon( + tab == AppTab.todos ? Icons.list : Icons.show_chart, + key: tab == AppTab.stats + ? ArchSampleKeys.statsTab + : ArchSampleKeys.todoTab, + ), + title: Text( + tab == AppTab.stats + ? ArchSampleLocalizations.of(context).stats + : ArchSampleLocalizations.of(context).todos, + ), + ); + }, + ).toList(), ); - }, - ).toList(), - ), + }), ); } } diff --git a/states_rebuilder/lib/ui/pages/home_screen/stats_counter.dart b/states_rebuilder/lib/ui/pages/home_screen/stats_counter.dart index 1b134031..1842a3cc 100644 --- a/states_rebuilder/lib/ui/pages/home_screen/stats_counter.dart +++ b/states_rebuilder/lib/ui/pages/home_screen/stats_counter.dart @@ -2,56 +2,57 @@ // Use of this source code is governed by the MIT license that can be found // in the LICENSE file. -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; +import 'package:states_rebuilder_sample/service/todos_state.dart'; import 'package:todos_app_core/todos_app_core.dart'; class StatsCounter extends StatelessWidget { - //use Injector.get, because this class need not to be reactive and its rebuild is ensured by its parent. - final todosService = Injector.get(); - StatsCounter() : super(key: ArchSampleKeys.statsCounter); @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 8.0), - child: Text( - ArchSampleLocalizations.of(context).completedTodos, - style: Theme.of(context).textTheme.title, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 24.0), - child: Text( - '${todosService.numCompleted}', - key: ArchSampleKeys.statsNumCompleted, - style: Theme.of(context).textTheme.subhead, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 8.0), - child: Text( - ArchSampleLocalizations.of(context).activeTodos, - style: Theme.of(context).textTheme.title, - ), + return StateBuilder( + observe: () => RM.get(), + builder: (_, todosStateRM) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: Text( + ArchSampleLocalizations.of(context).completedTodos, + style: Theme.of(context).textTheme.title, + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 24.0), + child: Text( + '${todosStateRM.state.numCompleted}', + key: ArchSampleKeys.statsNumCompleted, + style: Theme.of(context).textTheme.subhead, + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: Text( + ArchSampleLocalizations.of(context).activeTodos, + style: Theme.of(context).textTheme.title, + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 24.0), + child: Text( + '${todosStateRM.state.numActive}', + key: ArchSampleKeys.statsNumActive, + style: Theme.of(context).textTheme.subhead, + ), + ) + ], ), - Padding( - padding: EdgeInsets.only(bottom: 24.0), - child: Text( - '${todosService.numActive}', - key: ArchSampleKeys.statsNumActive, - style: Theme.of(context).textTheme.subhead, - ), - ) - ], - ), + ); + }, ); } } diff --git a/states_rebuilder/lib/ui/pages/home_screen/todo_item.dart b/states_rebuilder/lib/ui/pages/home_screen/todo_item.dart index 4091623d..d7ac990f 100644 --- a/states_rebuilder/lib/ui/pages/home_screen/todo_item.dart +++ b/states_rebuilder/lib/ui/pages/home_screen/todo_item.dart @@ -2,49 +2,57 @@ // Use of this source code is governed by the MIT license that can be found // in the LICENSE file. -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; import 'package:states_rebuilder_sample/domain/entities/todo.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; -import 'package:states_rebuilder_sample/ui/common/helper_methods.dart'; +import 'package:states_rebuilder_sample/service/todos_state.dart'; +import 'package:states_rebuilder_sample/ui/exceptions/error_handler.dart'; import 'package:states_rebuilder_sample/ui/pages/detail_screen/detail_screen.dart'; import 'package:todos_app_core/todos_app_core.dart'; class TodoItem extends StatelessWidget { final Todo todo; + //Accept the todo from the TodoList widget TodoItem({ Key key, @required this.todo, }) : super(key: key); - final todosServiceRM = Injector.getAsReactive(); - @override Widget build(BuildContext context) { return Dismissible( key: ArchSampleKeys.todoItem(todo.id), onDismissed: (direction) { - //delegate removing todo to the static method HelperMethods.removeTodo. - HelperMethods.removeTodo(todo); + removeTodo(context, todo); }, child: ListTile( - onTap: () { - Navigator.of(context).push( + onTap: () async { + final shouldDelete = await Navigator.of(context).push( MaterialPageRoute( builder: (_) { return DetailScreen(todo); }, ), ); + if (shouldDelete == true) { + removeTodo(context, todo); + } }, leading: Checkbox( key: ArchSampleKeys.todoItemCheckbox(todo.id), value: todo.complete, - onChanged: (complete) { - todo.complete = !todo.complete; - todosServiceRM.setState((state) => state.updateTodo(todo)); + onChanged: (value) { + final newTodo = todo.copyWith( + complete: value, + ); + //Here we get the global ReactiveModel, and use the stream method to call the updateTodo. + //states_rebuilder will subscribe to this stream and notify observer widgets to rebuild when data is emitted. + RM.get().setState( + (t) => TodosState.updateTodo(t, newTodo), + //on Error we want to display a snackbar + onError: ErrorHandler.showErrorSnackBar, + ); }, ), title: Text( @@ -62,4 +70,36 @@ class TodoItem extends StatelessWidget { ), ); } + + void removeTodo(BuildContext context, Todo todo) { + //get the global ReactiveModel, because we want to update the view of the list after removing a todo + final todosStateRM = RM.get(); + + todosStateRM.setState( + (t) => TodosState.deleteTodo(t, todo), + onError: ErrorHandler.showErrorSnackBar, + ); + + Scaffold.of(context).showSnackBar( + SnackBar( + key: ArchSampleKeys.snackbar, + duration: Duration(seconds: 2), + content: Text( + ArchSampleLocalizations.of(context).todoDeleted(todo.task), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + action: SnackBarAction( + label: ArchSampleLocalizations.of(context).undo, + onPressed: () { + //another nested call of stream method to voluntary add the todo back + todosStateRM.setState( + (t) => TodosState.addTodo(t, todo), + onError: ErrorHandler.showErrorSnackBar, + ); + }, + ), + ), + ); + } } diff --git a/states_rebuilder/lib/ui/pages/home_screen/todo_list.dart b/states_rebuilder/lib/ui/pages/home_screen/todo_list.dart index 449b1ef1..1cfacabb 100644 --- a/states_rebuilder/lib/ui/pages/home_screen/todo_list.dart +++ b/states_rebuilder/lib/ui/pages/home_screen/todo_list.dart @@ -4,39 +4,36 @@ import 'package:flutter/material.dart'; import 'package:states_rebuilder/states_rebuilder.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; -import 'package:states_rebuilder_sample/ui/exceptions/error_handler.dart'; import 'package:todos_app_core/todos_app_core.dart'; +import '../../../service/todos_state.dart'; import 'todo_item.dart'; class TodoList extends StatelessWidget { - TodoList() : super(key: ArchSampleKeys.todoList); - final todosServiceRM = Injector.getAsReactive(); @override Widget build(BuildContext context) { - //use whenConnectionState method to go through all the possible status of the ReactiveModel - return todosServiceRM.whenConnectionState( - onIdle: () => Container(), - onWaiting: () => Center( - child: CircularProgressIndicator( - key: ArchSampleKeys.todosLoading, - ), - ), - onData: (todosService) { + return StateBuilder( + //As this is the main list of todos, and this list can be update from + //many widgets and screens (FilterButton, ExtraActionsButton, AddEditScreen, ..) + //We register this widget with the global injected ReactiveModel. + //Anywhere in the widget tree if setState of todosStore is called this StatesRebuild + // will rebuild + //In states_rebuild global ReactiveModel is the model that can be invoked all across the widget tree + //and local ReactiveModel is a model that is meant to be called only locally in the widget where it is created + observe: () => RM.get(), + + builder: (context, todosStoreRM) { + //The builder exposes the BuildContext and the ReactiveModel of todosStore + final todos = todosStoreRM.state.todos; return ListView.builder( key: ArchSampleKeys.todoList, - itemCount: todosService.todos.length, + itemCount: todos.length, itemBuilder: (BuildContext context, int index) { - final todo = todosService.todos[index]; + final todo = todos[index]; return TodoItem(todo: todo); }, ); }, - onError: (error) { - //Delegate error handling to the static method ErrorHandler.getErrorMessage - return Center(child: Text(ErrorHandler.getErrorMessage(error))); - }, ); } } diff --git a/states_rebuilder/pubspec.yaml b/states_rebuilder/pubspec.yaml index 10e92960..32f3636f 100644 --- a/states_rebuilder/pubspec.yaml +++ b/states_rebuilder/pubspec.yaml @@ -19,7 +19,7 @@ environment: dependencies: flutter: sdk: flutter - states_rebuilder: ^1.11.2 + states_rebuilder: ^2.1.0 key_value_store_flutter: key_value_store_web: shared_preferences: diff --git a/states_rebuilder/test/detailed_screen_test.dart b/states_rebuilder/test/detailed_screen_test.dart new file mode 100644 index 00000000..bd93e9be --- /dev/null +++ b/states_rebuilder/test/detailed_screen_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:states_rebuilder_sample/app.dart'; +import 'package:states_rebuilder_sample/ui/pages/home_screen/todo_item.dart'; +import 'package:todos_app_core/todos_app_core.dart'; + +import 'fake_repository.dart'; + +void main() { + final todoItem1Finder = find.byKey(ArchSampleKeys.todoItem('1')); + + testWidgets('delete item from the detailed screen', (tester) async { + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository(), + ), + ); + + await tester.pumpAndSettle(); + //expect to see three Todo items + expect(find.byType(TodoItem), findsNWidgets(3)); + + //tap to navigate to detail screen + await tester.tap(todoItem1Finder); + await tester.pumpAndSettle(); + + //expect we are in the detailed screen + expect(find.byKey(ArchSampleKeys.todoDetailsScreen), findsOneWidget); + + // + await tester.tap(find.byKey(ArchSampleKeys.deleteTodoButton)); + await tester.pumpAndSettle(); + + //expect we are back in the home screen + expect(find.byKey(ArchSampleKeys.todoList), findsOneWidget); + //expect to see two Todo items + expect(find.byType(TodoItem), findsNWidgets(2)); + //expect to see a SnackBar to reinsert the deleted todo + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsOneWidget); + + //reinsert the deleted todo + await tester.tap(find.byType(SnackBarAction)); + await tester.pump(); + //expect to see three Todo items + expect(find.byType(TodoItem), findsNWidgets(3)); + await tester.pumpAndSettle(); + }); + + testWidgets('delete item from the detailed screen and reinsert it on error', + (tester) async { + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository() + ..throwError = true + ..delay = 1000, + ), + ); + + await tester.pumpAndSettle(); + + //tap to navigate to detail screen + await tester.tap(todoItem1Finder); + await tester.pumpAndSettle(); + + // + await tester.tap(find.byKey(ArchSampleKeys.deleteTodoButton)); + await tester.pumpAndSettle(); + + //expect we are back in the home screen + expect(find.byKey(ArchSampleKeys.todoList), findsOneWidget); + //expect to see two Todo items + expect(find.byType(TodoItem), findsNWidgets(2)); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsOneWidget); + + // + await tester.pump(Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); + + expect(find.byType(TodoItem), findsNWidgets(3)); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('There is a problem in saving todos'), findsOneWidget); + }); +} diff --git a/states_rebuilder/test/fake_repository.dart b/states_rebuilder/test/fake_repository.dart index d8fa18ff..34cea9af 100644 --- a/states_rebuilder/test/fake_repository.dart +++ b/states_rebuilder/test/fake_repository.dart @@ -1,31 +1,42 @@ import 'package:states_rebuilder_sample/domain/entities/todo.dart'; +import 'package:states_rebuilder_sample/service/exceptions/persistance_exception.dart'; import 'package:states_rebuilder_sample/service/interfaces/i_todo_repository.dart'; class FakeRepository implements ITodosRepository { @override - Future> loadTodos() { - return Future.value( - [ - Todo( - 'task1', - id: '1', - note: 'note1', - complete: true, - ), - Todo( - 'task2', - id: '2', - note: 'note2', - complete: false, - ), - ], - ); + Future> loadTodos() async { + await Future.delayed(Duration(milliseconds: delay ?? 20)); + return [ + Todo( + 'Task1', + id: '1', + note: 'Note1', + ), + Todo( + 'Task2', + id: '2', + note: 'Note2', + complete: false, + ), + Todo( + 'Task3', + id: '3', + note: 'Note3', + complete: true, + ), + ]; } + bool throwError = false; + int delay; bool isSaved = false; @override - Future saveTodos(List todos) { + Future saveTodos(List todos) async { + await Future.delayed(Duration(milliseconds: delay ?? 50)); + if (throwError) { + throw PersistanceException('There is a problem in saving todos'); + } isSaved = true; - return Future.value(true); + return true; } } diff --git a/states_rebuilder/test/home_screen_test.dart b/states_rebuilder/test/home_screen_test.dart new file mode 100644 index 00000000..eb965a28 --- /dev/null +++ b/states_rebuilder/test/home_screen_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:states_rebuilder_sample/app.dart'; +import 'package:todos_app_core/todos_app_core.dart'; +import 'fake_repository.dart'; + +/// Demonstrates how to test Widgets +void main() { + group('HomeScreen', () { + final todoListFinder = find.byKey(ArchSampleKeys.todoList); + final todoItem1Finder = find.byKey(ArchSampleKeys.todoItem('1')); + final todoItem2Finder = find.byKey(ArchSampleKeys.todoItem('2')); + final todoItem3Finder = find.byKey(ArchSampleKeys.todoItem('3')); + + testWidgets('should render loading indicator at first', (tester) async { + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository(), + ), + ); + await tester.pump(Duration.zero); + expect(find.byKey(ArchSampleKeys.todosLoading), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('should display a list after loading todos', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository(), + ), + ); + await tester.pumpAndSettle(); + + final checkbox1 = find.descendant( + of: find.byKey(ArchSampleKeys.todoItemCheckbox('1')), + matching: find.byType(Focus), + ); + final checkbox2 = find.descendant( + of: find.byKey(ArchSampleKeys.todoItemCheckbox('2')), + matching: find.byType(Focus), + ); + final checkbox3 = find.descendant( + of: find.byKey(ArchSampleKeys.todoItemCheckbox('3')), + matching: find.byType(Focus), + ); + + expect(todoListFinder, findsOneWidget); + expect(todoItem1Finder, findsOneWidget); + expect(find.text('Task1'), findsOneWidget); + expect(find.text('Note1'), findsOneWidget); + expect(tester.getSemantics(checkbox1), isChecked(false)); + expect(todoItem2Finder, findsOneWidget); + expect(find.text('Task2'), findsOneWidget); + expect(find.text('Note2'), findsOneWidget); + expect(tester.getSemantics(checkbox2), isChecked(false)); + expect(todoItem3Finder, findsOneWidget); + expect(find.text('Task3'), findsOneWidget); + expect(find.text('Note3'), findsOneWidget); + expect(tester.getSemantics(checkbox3), isChecked(true)); + + handle.dispose(); + }); + + testWidgets('should remove todos using a dismissible', (tester) async { + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository(), + ), + ); + await tester.pumpAndSettle(); + await tester.drag(todoItem1Finder, Offset(-1000, 0)); + await tester.pumpAndSettle(); + + expect(todoItem1Finder, findsNothing); + expect(todoItem2Finder, findsOneWidget); + expect(todoItem3Finder, findsOneWidget); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsOneWidget); + }); + + testWidgets( + 'should remove todos using a dismissible and insert back the removed element if throws', + (tester) async { + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository()..throwError = true, + ), + ); + + await tester.pumpAndSettle(); + await tester.drag(todoItem1Finder, Offset(-1000, 0)); + await tester.pumpAndSettle(); + + //Removed item in inserted back to the list + expect(todoItem1Finder, findsOneWidget); + expect(todoItem2Finder, findsOneWidget); + expect(todoItem3Finder, findsOneWidget); + //SnackBar with error message + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('There is a problem in saving todos'), findsOneWidget); + }); + + testWidgets('should display stats when switching tabs', (tester) async { + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(ArchSampleKeys.statsTab)); + await tester.pump(); + + expect(find.byKey(ArchSampleKeys.statsNumActive), findsOneWidget); + expect(find.byKey(ArchSampleKeys.statsNumActive), findsOneWidget); + }); + + testWidgets('should toggle a todo', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository(), + ), + ); + await tester.pumpAndSettle(); + + final checkbox1 = find.descendant( + of: find.byKey(ArchSampleKeys.todoItemCheckbox('1')), + matching: find.byType(Focus), + ); + expect(tester.getSemantics(checkbox1), isChecked(false)); + + await tester.tap(checkbox1); + await tester.pump(); + expect(tester.getSemantics(checkbox1), isChecked(true)); + + await tester.pumpAndSettle(); + handle.dispose(); + }); + + testWidgets('should toggle a todo and toggle back if throws', + (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + StatesRebuilderApp( + repository: FakeRepository()..throwError = true, + ), + ); + await tester.pumpAndSettle(); + + final checkbox1 = find.descendant( + of: find.byKey(ArchSampleKeys.todoItemCheckbox('1')), + matching: find.byType(Focus), + ); + expect(tester.getSemantics(checkbox1), isChecked(false)); + + await tester.tap(checkbox1); + await tester.pump(); + + expect(tester.getSemantics(checkbox1), isChecked(true)); + //NO Error, + expect(find.byType(SnackBar), findsNothing); + + // + await tester.pumpAndSettle(); + expect(tester.getSemantics(checkbox1), isChecked(false)); + + //SnackBar with error message + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('There is a problem in saving todos'), findsOneWidget); + handle.dispose(); + }); + }); +} + +Matcher isChecked(bool isChecked) { + return matchesSemantics( + isChecked: isChecked, + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + ); +} diff --git a/states_rebuilder/test/todo_service_test.dart b/states_rebuilder/test/todo_service_test.dart deleted file mode 100644 index ae91c27f..00000000 --- a/states_rebuilder/test/todo_service_test.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:states_rebuilder_sample/domain/entities/todo.dart'; -import 'package:states_rebuilder_sample/service/common/enums.dart'; -import 'package:states_rebuilder_sample/service/interfaces/i_todo_repository.dart'; -import 'package:states_rebuilder_sample/service/todos_service.dart'; - -import 'fake_repository.dart'; - -//TodoService class is a pure dart class, you can test it just as you test a plain dart class. -void main() { - group( - 'TodosService', - () { - ITodosRepository todosRepository; - TodosService todoService; - setUp( - () { - todosRepository = FakeRepository(); - todoService = TodosService(todosRepository); - }, - ); - - test( - 'should load todos works', - () async { - expect(todoService.todos.isEmpty, isTrue); - await todoService.loadTodos(); - expect(todoService.todos.length, equals(2)); - }, - ); - - test( - 'should filler todos works', - () async { - await todoService.loadTodos(); - //all todos - expect(todoService.todos.length, equals(2)); - //active todos - todoService.activeFilter = VisibilityFilter.active; - expect(todoService.todos.length, equals(1)); - //completed todos - todoService.activeFilter = VisibilityFilter.completed; - expect(todoService.todos.length, equals(1)); - }, - ); - - test( - 'should add todo works', - () async { - await todoService.loadTodos(); - expect(todoService.todos.length, equals(2)); - final todoToAdd = Todo('addTask'); - await todoService.addTodo(todoToAdd); - expect(todoService.todos.length, equals(3)); - expect(await (todosRepository as FakeRepository).isSaved, isTrue); - }, - ); - - test( - 'should update todo works', - () async { - await todoService.loadTodos(); - final beforeUpdate = - todoService.todos.firstWhere((todo) => todo.id == '1'); - expect(beforeUpdate.task, equals('task1')); - await todoService.updateTodo(Todo('updateTodo', id: '1')); - expect(await (todosRepository as FakeRepository).isSaved, isTrue); - final afterUpdate = - todoService.todos.firstWhere((todo) => todo.id == '1'); - expect(afterUpdate.task, equals('updateTodo')); - }, - ); - - test( - 'should delete todo works', - () async { - await todoService.loadTodos(); - expect(todoService.todos.length, equals(2)); - await todoService.deleteTodo(Todo('updateTodo', id: '1')); - expect(await (todosRepository as FakeRepository).isSaved, isTrue); - expect(todoService.todos.length, equals(1)); - }, - ); - - test( - 'should toggleAll todos works', - () async { - await todoService.loadTodos(); - expect(todoService.numActive, equals(1)); - expect(todoService.numCompleted, equals(1)); - - await todoService.toggleAll(); - expect(await (todosRepository as FakeRepository).isSaved, isTrue); - expect(todoService.numActive, equals(0)); - expect(todoService.numCompleted, equals(2)); - - await todoService.toggleAll(); - expect(todoService.numActive, equals(2)); - expect(todoService.numCompleted, equals(0)); - }, - ); - - test( - 'should clearCompleted todos works', - () async { - await todoService.loadTodos(); - expect(todoService.numActive, equals(1)); - expect(todoService.numCompleted, equals(1)); - - await todoService.clearCompleted(); - expect(await (todosRepository as FakeRepository).isSaved, isTrue); - expect(todoService.todos.length, equals(1)); - expect(todoService.numActive, equals(1)); - expect(todoService.numCompleted, equals(0)); - }, - ); - }, - ); -} diff --git a/states_rebuilder/test/todo_state_test.dart b/states_rebuilder/test/todo_state_test.dart new file mode 100644 index 00000000..d9d2c791 --- /dev/null +++ b/states_rebuilder/test/todo_state_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:states_rebuilder_sample/domain/entities/todo.dart'; +import 'package:states_rebuilder_sample/service/common/enums.dart'; +import 'package:states_rebuilder_sample/service/exceptions/persistance_exception.dart'; +import 'package:states_rebuilder_sample/service/interfaces/i_todo_repository.dart'; +import 'package:states_rebuilder_sample/service/todos_state.dart'; + +import 'fake_repository.dart'; + +//TodoService class is a pure dart class, you can test it just as you test a plain dart class. +void main() { + group( + 'TodosState', + () { + ITodosRepository todosRepository; + TodosState todosState; + setUp( + () { + todosRepository = FakeRepository(); + todosState = TodosState( + todos: [], + activeFilter: VisibilityFilter.all, + todoRepository: todosRepository, + ); + }, + ); + + test( + 'should load todos works', + () async { + final todosNewState = await TodosState.loadTodos(todosState); + expect(todosNewState.todos.length, equals(3)); + }, + ); + + test( + 'should filler todos works', + () async { + var todosNewState = await TodosState.loadTodos(todosState); + //all todos + expect(todosNewState.todos.length, equals(3)); + //active todos + todosNewState = + todosNewState.copyWith(activeFilter: VisibilityFilter.active); + expect(todosNewState.todos.length, equals(2)); + //completed todos + todosNewState = + todosNewState.copyWith(activeFilter: VisibilityFilter.completed); + expect(todosNewState.todos.length, equals(1)); + }, + ); + + test( + 'should add todo works', + () async { + var startingTodosState = await TodosState.loadTodos(todosState); + + final todoToAdd = Todo('addTask'); + final expectedTodosState = startingTodosState.copyWith( + todos: List.from(startingTodosState.todos)..add(todoToAdd), + ); + + expect( + TodosState.addTodo(startingTodosState, todoToAdd), + emitsInOrder([expectedTodosState, emitsDone]), + ); + }, + ); + + test( + 'should add todo and remove it on error', + () async { + var startingTodosState = await TodosState.loadTodos(todosState); + + final todoToAdd = Todo('addTask'); + + (todosRepository as FakeRepository).throwError = true; + final expectedTodosState = startingTodosState.copyWith( + todos: List.from(startingTodosState.todos)..add(todoToAdd), + ); + + expect( + TodosState.addTodo(startingTodosState, todoToAdd), + emitsInOrder([ + expectedTodosState, + startingTodosState, + emitsError(isA()), + emitsDone, + ]), + ); + }, + ); + + test( + 'should update todo works', + () async { + var startingTodosState = await TodosState.loadTodos(todosState); + + final updatedTodo = + startingTodosState.todos.first.copyWith(task: 'updated task'); + + final expectedTodos = List.from(startingTodosState.todos); + expectedTodos[0] = updatedTodo; + final expectedTodosState = startingTodosState.copyWith( + todos: expectedTodos, + ); + + expect( + TodosState.updateTodo(startingTodosState, updatedTodo), + emitsInOrder([expectedTodosState, emitsDone]), + ); + }, + ); + + test( + 'should delete todo works', + () async { + var startingTodosState = await TodosState.loadTodos(todosState); + + final expectedTodosState = startingTodosState.copyWith( + todos: List.from(startingTodosState.todos)..removeLast(), + ); + + expect( + TodosState.deleteTodo( + startingTodosState, startingTodosState.todos.last), + emitsInOrder([expectedTodosState, emitsDone]), + ); + }, + ); + + test( + 'should toggleAll todos works', + () async { + var startingTodosState = await TodosState.loadTodos(todosState); + + expect(startingTodosState.numActive, equals(2)); + expect(startingTodosState.numCompleted, equals(1)); + + var expectedTodosState = startingTodosState.copyWith( + todos: startingTodosState.todos + .map( + (t) => + t.copyWith(complete: !startingTodosState.allComplete), + ) + .toList()); + + expect( + TodosState.toggleAll(startingTodosState), + emitsInOrder([expectedTodosState, emitsDone]), + ); + expect(expectedTodosState.numActive, equals(0)); + expect(expectedTodosState.numCompleted, equals(3)); + }, + ); + + test( + 'should clearCompleted todos works', + () async { + var startingTodosState = await TodosState.loadTodos(todosState); + + expect(startingTodosState.numActive, equals(2)); + expect(startingTodosState.numCompleted, equals(1)); + + var expectedTodosState = startingTodosState.copyWith( + todos: startingTodosState.todos + .where( + (t) => !t.complete, + ) + .toList()); + + expect( + TodosState.clearCompleted(startingTodosState), + emitsInOrder([expectedTodosState, emitsDone]), + ); + }, + ); + }, + ); +}