Stacked Framework
repository·master·Indexed 21 days ago
https://github.com/stacked-org/stackedA comprehensive frontend architecture framework for Flutter designed to facilitate the creation of testable and maintainable production applications. It provides a structured approach to organizing codebases, including tools for reactive state management via ReactiveList and ReactiveValue, route lifecycle monitoring with RouteAware and RouteAwareStateMixin, and advanced routing capabilities through NestedRouterDelegate and StackedRouteGuard.
What's inside Stacked
- Stacked is a complete frontend architecture framework for Flutter designed to help developers build production-ready, testable, and maintainable applications. It provides a structured approach to organizing Flutter codebases.
What is Stacked architecture
masterStacked is an architecture for Flutter applications based on MVVM (Model-View-ViewModel) principles. It is designed to provide common functionalities that make app development easier while enforcing code principles that ensure maintainability.
The architecture is built around three major components:
- View: A UI representation that shows the interface to the user. Views should contain minimal to no logic and should only render the state provided by their ViewModel.
- ViewModel: Manages the state of the View, handles business logic, and processes user interactions. It interacts with Services to perform tasks.
- Services: Wrappers for specific functionalities or feature sets (e.g., API integration, database access, or showing dialogs).
Core Principles:
- Views must never use Services directly.
- Views should only render the state from their ViewModel.
- ViewModels should not know about other ViewModels.
- ViewModels for page-level views are typically bound to a single View, but can be reused for widgets if the UI requires the same functionality.
How to implement reactivity between Services and ViewModels
masterStacked provides a mechanism for ViewModels to react to state changes in shared services using the
ReactiveServiceMixinandReactiveViewModel.- In the Service: Use
ReactiveServiceMixinto allow the service to notify listeners. When updating state, callnotifyListeners(). - In the ViewModel: Extend
ReactiveViewModeland override thereactiveServicesgetter to return a list of the services this ViewModel needs to listen to.
This ensures that whenever a service calls
notifyListeners(), any ViewModel that has registered that service in itsreactiveServiceslist will be notified and can trigger UI updates.// 1. The Service class PostsService with ReactiveServiceMixin { int _postCount = 0; int get postCount => _postCount; void setPostCount(int count) { _postCount = count; notifyListeners(); // Notifies registered ViewModels } } // 2. The ViewModel class AnyViewModel extends ReactiveViewModel { final _postsService = locator<PostsService>(); int get postCount => _postsService.postCount; @override List<ReactiveServiceMixin> get reactiveServices => [_postsService]; }- In the Service: Use
How ViewModelBuilder works in Stacked
masterThe
ViewModelBuildercreates the binding between aViewModel(a class extendingChangeNotifier) and aView. It wrapsChangeNotifierProviderlogic to trigger widget rebuilds whennotifyListeners()is called within the ViewModel.There are two primary modes of binding:
- Reactive: The default mode. The
builderfunction re-executes whenever the ViewModel callsnotifyListeners(), allowing the UI to stay in sync with the state. - Non-Reactive: Used when you want to provide a ViewModel to multiple child widgets without triggering a rebuild of the entire parent tree. In this mode, you use
ViewModelWidgetorSelectorViewModelWidgetin the children to specify exactly which parts of the UI should rebuild when the state changes.
// Reactive binding example return ViewModelBuilder<HomeViewModel>.reactive( viewModelBuilder: () => HomeViewModel(), onModelReady: (viewModel) => viewModel.initialise(), builder: (context, viewModel, child) => Scaffold( body: Center(child: Text(viewModel.title)), ), );- Reactive: The default mode. The
Implement Nested Navigation
masterNested navigation is achieved by declaring routes within the
childrenproperty of a parent route in the@StackedAppannotation.- Define Routes: Add child routes to a parent
MaterialRoute. - Render Navigator: In the parent view, use the
ExtendedNavigatorwidget. The router name is generated by appendingRouterto the parent page name (e.g.,OtherNavigatorbecomesOtherNavigatorRouter). - Navigate: Use
NavigationService.navigateToproviding the generated route and anidthat matches thenestedNavigationKeyused in theExtendedNavigator.
// 1. Route Definition @StackedApp(routes: [ MaterialRoute(page: HomeView, initial: true), MaterialRoute(page: OtherNavigator, children: [ MaterialRoute(page: OtherView, initial: true), MaterialRoute(page: OtherNestedView), ]), ], ) // 2. Rendering the Nested Navigator class OtherNavigator extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: ExtendedNavigator(router: OtherNavigatorRouter(), navigatorKey: StackedService.nestedNavigationKey(1)), ); } } // 3. Navigating _navigationService.navigateTo(OtherNavigatorRoutes.otherNestedView, id: 1);- Define Routes: Add child routes to a parent
Use BaseViewModel for busy state management
masterBaseViewModelis aChangeNotifierthat provides built-in support for managingGetting started with the Navigator example project
masterThenavigator_exampleproject serves as a starting point for a Flutter application demonstrating the new architecture. If you are new to Flutter, it is recommended to follow the official Flutter codelabs and cookbooks to understand the fundamentals of mobile development before diving into the specific architecture of this project.Implement per-field validation
masterStacked provides automated per-field validation when using
@FormView. For every field defined, the generator creates specific methods to manage its error state:set[FieldName]ValidationMessage(String? message): Call this insetFormStatusto set the error message for a specific field. Passnullif the field is valid.has[FieldName]ValidationMessage: A boolean flag to check if the specific field has an error.[fieldName]ValidationMessage: The error string to display in the UI for that field.
Example logic: A validator function should return a
Stringcontaining the error message ornullif the input is valid.class ExampleFormViewModel extends FormViewModel { @override void setFormStatus() { // Set the validation message per field setPasswordValidationMessage(passwordValidator(value: passwordValue)); // Set a validation message for the entire form if a field is invalid if (hasPasswordValidationMessage) { setFormValidationMessage('Error in the form, please check again'); } } } // In the View if (viewModel.hasPasswordValidationMessage) Text( viewModel.passwordValidationMessage!, style: TextStyle(color: Colors.red), ),Initialize the Locator
masterAfter running the code generator, a
setupLocator()function is created inapp.locator.dart. This must be called in yourmain.dartbeforerunApp().If you have any
Presolvedependencies,main()must be anasyncfunction and you mustawait setupLocator().void main() { setupLocator(); runApp(MyApp()); } // If using Presolve dependencies: Future main() async { await setupLocator(); runApp(MyApp()); }Handle errors in BaseViewModel
masterWhen using
runBusyFutureorrunErrorFuture, Stacked automatically catches exceptions, sets the busy state tofalse, and stores the error for you.Retrieving Errors
- Global error: If no key was provided to the future, check
viewModel.hasErrorand retrieve the exception viaviewModel.modelError. - Keyed error: If a
busyObjectkey was provided, check for the error usingviewModel.hasErrorForKey(key)and retrieve it viaviewModel.error(key).
Reacting to Errors
You can override
onFutureError(Exception error, dynamic key)in your ViewModel to perform specific logic when a future fails.class ErrorExampleViewModel extends BaseViewModel { Future longUpdateStuff() async { // Automatically catches errors and sets busy to false await runBusyFuture(updateStuff()); } Future updateStuff() async { await Future.delayed(const Duration(seconds: 3)); throw Exception('Things went wrong'); } } // Accessing the error in UI if (viewModel.hasError) { print(viewModel.modelError); } // Accessing keyed error if (viewModel.hasErrorForKey(BusyObjectKey)) { print(viewModel.error(BusyObjectKey)); }- Global error: If no key was provided to the future, check
Implement global form validation
masterTo implement validation for an entire form, extend
FormViewModel. You can manage the global validation state using these methods within thesetFormStatusoverride:setFormValidationMessage(String message): Sets a global error message for the entire form.showFormValidationMessage: A boolean flag used in the View to determine if a global error message should be displayed.formValidationMessage: The actual string message to display in the UI.
In the View, check
viewModel.showFormValidationMessageto conditionally render the error text.class ExampleFormViewModel extends FormViewModel { @override void setFormStatus() { // Set a validation message for the entire form if (<any unmet condition>) { setFormValidationMessage('Error in the form, please check again'); } } } // In the View if (viewModel.showFormValidationMessage) Text( viewModel.formValidationMessage!, style: TextStyle(color: Colors.red), ),Generate form fields with @FormView
masterYou can automate form field generation using the
stacked_generatorpackage. By adding the@FormViewdecoration to your View class, you can define fields likeFormTextField,FormDateField, andFormDropdownField.After defining the annotation, run the following command to generate the necessary mixin:
flutter pub run build_runner build --delete-conflicting-outputsOnce generated, use the mixin in your View (e.g.,
with $ExampleFormView). To ensure the form values are tracked and cleaned up, use theonModelReadyandonDisposecallbacks in yourViewModelBuilder:onModelReady: (viewModel) => listenToFormUpdated(viewModel), onDispose: (_) => disposeForm(),To access the actual values (e.g.,
emailValue,passwordValue) in your ViewModel, import the generated file.@FormView(fields: [ FormTextField(name: 'email', initialValue: "Lorem"), FormTextField(name: 'password', isPassword: true), FormTextField(name: 'shortBio'), FormDateField(name: 'birthDate'), FormDropdownField( name: 'doYouLoveFood', items: [ StaticDropdownItem(title: 'Yes', value: 'YesDr'), StaticDropdownItem(title: 'No', value: 'NoDr'), ], ) ]) class ExampleFormView extends StatelessWidget with $ExampleFormView { ExampleFormView({Key? key}) : super(key: key); }