Write a feature module
The feature counter has one screen that counts taps on its button, and it does most of what a feature can do:
- it declares a route for whatever router the app has, and a tab in the main navigation;
- it registers a service of its own,
CounterStore, in the DI container; - it keeps the count in a Cubit with BLoC or in a Notifier with Riverpod, through a variant for each state manager;
- its composition file takes the store from the container, and its screens talk only to their state.
The code below was generated into apps with both state managers and checked with flutter analyze.
The descriptor
import 'package:smf_contracts/smf_contracts.dart';
import 'package:smf_counter/bundles/counter_bloc_bundle.dart';
import 'package:smf_counter/bundles/counter_bundle.dart';
import 'package:smf_counter/bundles/counter_riverpod_bundle.dart';
/// A feature with a screen that counts taps on its button, with a variant
/// for each module that manages state.
final class CounterModule extends SmfModule {
/// Creates the module.
const CounterModule();
/// The id of the module.
static const id = ModuleId('counter');
static const _store = ImportRef.app('features/counter/counter_store.dart');
/// The label of the tab of the counter.
static const _label = LocalizedText(
'label',
en: 'Counter',
translations: {'uk': 'Лічильник'},
);
ModuleDescriptor get descriptor => ModuleDescriptor(
id: id,
description: 'A screen that counts taps',
kind: ModuleKinds.feature,
// The composition file resolves the store.
requires: const {diRole},
// The label of the tab follows the language of the app.
uses: const {localizationRole},
variants: Variants(
role: stateManagementRole,
byProvider: {
const ModuleId('bloc'): (context) => [
BrickContribution(counterBlocBundle),
const PubspecContribution.hosted('flutter_bloc', 'any'),
],
const ModuleId('riverpod'): (context) => [
BrickContribution(counterRiverpodBundle),
const PubspecContribution.hosted('flutter_riverpod', 'any'),
],
},
),
);
// ... contribute(), below.
}
kind: ModuleKinds.featuremakes the module require the router role and keep its files inlib/features/counter/. It also lets the module resolve services in one place, its composition file.requires: {diRole}is there because the composition file resolves the store, so the feature needs a DI container. When one module provides it,smf createadds that module,get_it, by itself.uses: {localizationRole}is there for the label of the tab, a text that the app shows in its language. The module only uses the role, so it works in an app without it, where the label is in English.variantshas the contributions for each provider of the state management role, keyed by the provider's id. The feature does not depend onsmf_blocorsmf_riverpod: the keys are plain values. Each variant brings the package of its state manager with the constraintany, so the version stays with the module that provides the role. A module with variants requires their role, so an app with the counter always has a state manager.
The store and its registration
The store is a plain class in the brick counter, which every app with the counter gets:
/// Keeps the count of the counter screen while the app runs.
class CounterStore {
/// The number of taps so far.
int count = 0;
}
/// Creates the store; the DI container of the app calls it.
CounterStore createCounterStore() => CounterStore();
The module registers the store as data of the DI role, with its type and the top-level function that creates it. The default lifetime is a lazy singleton, which is created on first use:
List<Contribution> contribute(ModuleContext context) => [
BrickContribution(counterBundle),
diRole.data(
const DiRegistration(
type: TypeRef('CounterStore', import: _store),
create: FactoryRef('createCounterStore', import: _store),
),
),
// ... the label and the route, below.
];
ImportRef.app names a file of the app by its path below lib/, without the package name of the app, which the module does not know. With get_it, the registration becomes this line of registerDependencies():
getIt.registerLazySingleton<di0.CounterStore>(() => di0.createCounterStore());
A factory that needs other services lists them in FactoryRef.deps, and the container passes them as arguments, in that order.
The route and the tab
The feature gives the localization role the label of its tab, and declares its routes as data of the router role:
localizationRole.data(const TextsData([_label])),
routerRole.data(
const RoutesData([
Route(
'/',
name: 'counter',
screen: ScreenRef(
'CounterScreen',
import: ImportRef.app('features/counter/counter_screen.dart'),
),
destination: Destination(
label: _label,
icon: Fragment(
'Icons.add',
imports: [
ImportRef('package:flutter/material.dart', show: ['Icons']),
],
),
),
),
]),
),
- The path
/is relative to the feature, so the full path is/counter, and the namecountermakes the full namecounter.counterand the methodcontext.nav.counter.counter(). destinationputs the route into the main navigation, as a tab with a label and an icon, when the app has a layout. The icon is a constant expression of typeIconDatawith the imports it needs.- The label is a
LocalizedText, a text of the module with its translations. The module gives the localization role the same text, so in an app with that role the tab reads it from the texts of the app, ascontext.l10n.counterLabel, and follows the language. In an app without the role, the tab shows the English text. - The route has no
startCandidate: true, so the app does not start on it. Mark a route as a start candidate when the app can open on it. When several routes can,smf createasks which one.
A module can also do without the localization role. Its label is then the English text alone, which every app shows:
label: LocalizedText('label', en: 'Counter'),
smf create reports a translation of such a label, which no app would show. In an app with the localization role, it also reports a module that lists the role and does not give it the label.
The BLoC variant
The brick counter_bloc has the composition file and the screen for BLoC. The composition file creates the Cubit with the services it needs. It is the only file of the feature that calls resolve:
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/di/service_locator.dart';
import 'counter_store.dart';
/// Creates the cubit of the counter screen with the services it needs.
CounterCubit createCounterCubit() => CounterCubit(resolve<CounterStore>());
/// The count of the counter screen.
class CounterCubit extends Cubit<int> {
/// Creates the cubit that keeps its count in [_store].
CounterCubit(this._store) : super(_store.count);
final CounterStore _store;
/// Counts a tap.
void increment() {
_store.count++;
emit(_store.count);
}
}
The screen provides the Cubit and talks only to it:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_composition.dart';
/// Shows how many times its button was tapped.
{{{smf_router__screen_annotations__counter__counter_screen}}}
class CounterScreen extends StatelessWidget {
/// Creates the screen.
const CounterScreen({super.key});
Widget build(BuildContext context) => BlocProvider(
create: (_) => createCounterCubit(),
child: Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text('$count'),
),
),
floatingActionButton: Builder(
builder: (context) => FloatingActionButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: const Icon(Icons.add),
),
),
),
);
}
The line {{{smf_router__screen_annotations__counter__counter_screen}}} is the tag of a socket of the router role, where a router that needs annotations on its screens, such as @RoutePage(), puts them. Every screen of a route has this tag on its own line right before its class, named after the feature and the class. With go_router the tag renders to nothing, and the line goes away.
The Riverpod variant
The brick counter_riverpod has the same two files for Riverpod. Its composition file bridges the container to a provider:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/di/service_locator.dart';
import 'counter_store.dart';
/// The store of the counter, from the service locator of the app.
final counterStoreProvider = Provider<CounterStore>(
(_) => resolve<CounterStore>(),
);
/// The count of the counter screen.
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
/// Counts the taps on the counter screen.
class Counter extends Notifier<int> {
int build() => ref.read(counterStoreProvider).count;
/// Counts a tap.
void increment() {
final store = ref.read(counterStoreProvider);
store.count++;
state = store.count;
}
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'counter_composition.dart';
/// Shows how many times its button was tapped.
{{{smf_router__screen_annotations__counter__counter_screen}}}
class CounterScreen extends ConsumerWidget {
/// Creates the screen.
const CounterScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(child: Text('${ref.watch(counterProvider)}')),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Icon(Icons.add),
),
);
}
Both variants generate files at the same paths. That works because an app gets only one of them.
A look that the variants share
The two variants of the counter each have a whole screen, which is fine for a screen of a few lines. A feature with more to show keeps the look out of its variants, as the built-in sign_in does with its four screens:
| Brick | In | What it has |
|---|---|---|
sign_in | every app with the module | The look: the views, such as SignInView, their widgets, the values of the state and the functions of the guards. A view is a plain Flutter widget that gets the state of its call and callbacks, and imports nothing of a state manager. |
sign_in_bloc | the variant for bloc | The screens, a cubit for each, and the file that creates the cubits. |
sign_in_riverpod | the variant for riverpod | The same screens, and a provider for each with its Notifier. |
The screens are at the same paths in both variants, such as lib/features/sign_in/sign_in_screen.dart, and each only gives its view the state. The descriptor names a function for each variant:
variants: Variants(
role: stateManagementRole,
// Each variant imports the package of its provider, whose
// constraint the provider owns.
byProvider: {blocVariant: _bloc, riverpodVariant: _riverpod},
),
A variant contributes like the module does, so it can bring more than a brick. The variant for bloc also puts a cubit around the root of the app, for the widgets that read who uses the app, and adds its part of the note for coding agents:
static List<Contribution> _bloc(ModuleContext context) => [
BrickContribution(signInBlocBundle),
const PubspecContribution.hosted('flutter_bloc', 'any'),
// Who uses the app, for every widget of the app.
const SocketContribution.wrap(
AppEntryRole.rootWrappers,
Fragment.wrap(
'BlocProvider(create: (_) => createSessionCubit(), child: ',
')',
imports: [
ImportRef(
'package:flutter_bloc/flutter_bloc.dart',
show: ['BlocProvider'],
),
ImportRef.app(
'$_folder/sign_in_composition.dart',
show: ['createSessionCubit'],
),
],
),
),
AppEntryRole.agentSections.entry(
agentHeading,
AgentNote(blocAgentNote),
),
];
With the look in one place, a change of a field or of a text is one change. Test a module shows how the state of each variant is tested.
Generate an app with it
Add the module to a command of your own, as Extending SMF shows, bundle the bricks, and generate an app:
dart run bin/my_smf.dart create counter_app --org com.example -m home,counter,bottom_tabs,bloc --no-input
Adding flutter_core: the only provider of the app entry role, which every app needs.
Adding go_router: the only provider of the router role, which home requires.
Adding get_it: the only provider of the dependency injection role, which counter requires.
...
Created counter_app in /Users/you/projects/counter_app.
The app has two tabs, Home and Counter, and lib/features/counter/ holds the store, the composition file and the screen of the BLoC variant. With -m home,counter,bottom_tabs,riverpod, it gets the Riverpod variant instead. Without a state manager in -m, a run in a terminal asks for one, and a run without a terminal stops with a usage error.
What the pipeline checks
Before it generates an app, smf create checks, among other things, that:
- every file of the module is in
lib/features/counter/; - the module declares at least one route, with valid paths, names, screens and parameters, and every route can be reached;
- in an app with the localization role, the label of its tab is a text that the module gave the role;
- the template of every screen has the tag of its annotations;
- the module has a variant for the provider in the app, and the packages of a state manager come only through its variant.
The contract harness runs these checks too, and checks the generated code:
- the unnamed constructor of every screen is
constand takes the parameters of its route; - only the composition file resolves services, and the module requires the DI role for it;
- the function of every registration is a top-level function of its file that takes the services it lists;
- the code navigates only to the module's own routes and to those of the modules it depends on.
Routes with parameters and children
A route can have children, shown on top of it, and parameters from its path and its query:
Route(
'/',
name: 'list',
screen: ScreenRef(
'CatalogScreen',
import: ImportRef.app('features/catalog/catalog_screen.dart'),
),
startCandidate: true,
children: [
Route(
'item/:id',
name: 'item',
screen: ScreenRef(
'ItemScreen',
import: ImportRef.app('features/catalog/item_screen.dart'),
),
params: [
RouteParam.path('id', type: int),
RouteParam.query('color', type: String, optional: true),
],
),
],
),
-
A child's path has no leading
/and is relative to its parent, so this one is/catalog/item/:id. -
A parameter is a
String, anint, adoubleor abool. A path parameter is always required, and a query parameter is required unless it isoptional. -
The screen's unnamed constructor takes each parameter as a named parameter of the same name, nullable when optional. The template puts the tag of the parameter's annotations before it, on a line of its own:
const ItemScreen({
{{{smf_router__param_annotations__catalog__item_screen__id}}}
required this.id,
{{{smf_router__param_annotations__catalog__item_screen__color}}}
this.color,
super.key,
}); -
A parameter cannot take a name that the facade uses itself, which means
key,path,parent,chainandrouteName, or the name of a member ofObjector of adart:coretype in lowercase, such asint. -
A route with children takes no required query parameters, since navigating to a child rebuilds its parents from the path alone.
-
A router matches a location against the routes in the order a module declares them, each followed by its children, and the first match wins. Declare a route with a fixed segment, such as
/new, before a route with a parameter in its place, such as/:id. In an app with a main navigation, the router matches the destinations and their children first. SMF reports a route that another route leaves unreachable. -
A destination must be a top-level route without required parameters, since selecting it has no values to give. A start candidate takes no required parameters either.
The screen of the list navigates to an item through the facade:
TextButton(
onPressed: () => context.nav.catalog.item(id: 5).push<void>(),
child: const Text('Item 5'),
)
A feature navigates only to its own routes, and to those of features it depends on with dependsOn, through context.nav or their location classes.
A guard
A feature can keep the user from the rest of the app until a condition holds, with a guard over the whole app, a gate. The built-in onboarding is such a feature. The smaller feature terms of this section shows its screen in place of every other screen until the user accepts the terms, and remembers the answer. It needs no DI container: its state is a notifier of its own, which a restorer fills from the preferences. It declares the guard next to its route:
static const _status = ImportRef.app('features/terms/terms_status.dart');
ModuleDescriptor get descriptor => const ModuleDescriptor(
id: id,
description: 'Terms that the user accepts before using the app',
kind: ModuleKinds.feature,
// The answer is saved in the preferences.
requires: {preferencesRole},
);
List<Contribution> contribute(ModuleContext context) => [
BrickContribution(termsBundle),
routerRole.data(
const RoutesData(
[
Route(
'/',
name: 'terms',
screen: ScreenRef(
'TermsScreen',
import: ImportRef.app('features/terms/terms_screen.dart'),
),
),
],
guards: [
RouteGuard(
name: 'accepted',
allows: FunctionRef('termsAccepted', import: _status),
redirectTo: 'terms',
stage: GuardStage.welcome,
),
],
),
),
const SocketContribution.item(
PreferencesRole.restorers,
Fragment('restoreTerms', imports: [_status]),
),
];
allowsnames a top-level function of a file of the app, without parameters, that returns aValueListenable<bool>. The router role imports the file with a prefix of its own.redirectTonames the target of the guard: a top-level route of the feature that needs no values and is not a destination of the main navigation. The target and the routes below it are the flow of the guard, and none of them is a start candidate.stageis required: every guard is of the stageGuardStage.welcomeorGuardStage.identity, which says when the app asks it among its gates. The terms need to know nothing of the user, so the guard has the stageGuardStage.welcome, and the app asks it before a guard of the stageGuardStage.identity, which asks who the user is. Among the guards of one stage, the order of the modules decides. See the order of the guards.- The guard leaves
resumesattrue. If it stops allowing while the app runs, the router brings the user back to where they were once it allows again. A guard whose next user should start anew, as after a sign-out, setsresumes: false. - The feature requires the preferences role, because it saves the answer there. With one module that provides the role,
smf createadds it by itself. Add a setting describes the restorer.
The function of the guard and what it reads are in a file of the feature:
import 'package:flutter/foundation.dart';
import '../../core/preferences/app_preferences.dart';
/// The key of the answer in the preferences of the app.
const _key = 'terms.accepted';
final ValueNotifier<bool> _accepted = ValueNotifier(false);
AppPreferences? _preferences;
/// Whether the user has accepted the terms; the guard of the feature
/// listens to it.
ValueListenable<bool> termsAccepted() => _accepted;
/// Accepts the terms and saves the answer.
Future<void> acceptTerms() async {
_accepted.value = true;
await _preferences?.setBool(_key, true);
}
/// Takes the answer that is saved, and keeps [preferences] for the answer
/// to come; the app calls it before its first frame.
void restoreTerms(AppPreferences preferences) {
_preferences = preferences;
_accepted.value = preferences.getBool(_key) ?? _accepted.value;
}
The screen of the flow only changes what the guard reads:
import 'package:flutter/material.dart';
import 'terms_status.dart';
/// Shows the terms of the app until the user accepts them.
{{{smf_router__screen_annotations__terms__terms_screen}}}
class TermsScreen extends StatelessWidget {
/// Creates the screen.
const TermsScreen({super.key});
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Terms')),
body: const Center(
child: FilledButton(onPressed: acceptTerms, child: Text('Accept')),
),
);
}
- The app calls
termsAccepted()once, when its router first asks the guards, which is afterbootstrap(). The restorer has run by then, so the value is known without waiting. - The value changes in the handler of a tap, outside the build of a frame, because the router navigates when it changes.
- The screen navigates nowhere, and it does not close itself. Once the guard allows, the router leaves the flow for the location that the user asked for, or for the screen that the app starts on.
- The route shows only while the guard does not allow. Once the terms are accepted, a navigation to
/terms, such as a link, shows the screen that the app starts on. So no code navigates to the terms: a module that wants to show them again changes what the guard reads. - A feature whose guard needs a service keeps the function in its composition file, which may resolve services.
In an app with -m home,terms, the app opens on the terms. go(), push() and replace() of the home screen show the terms too, and once the user taps Accept, the app shows the home screen. smf create refuses --start /terms, since the route is in the flow of the guard.
Before it generates an app, smf create checks the name of each guard, its function and its target. The contract harness also checks that the function is a top-level function of its file that takes no arguments and returns a ValueListenable<bool>, and that the files of the module that provides the router ask the guards through the GuardedNavigation of the role. Whether a router does what the guards answer, only a running app shows; see Guards of routes.
A route that asks for a condition
A gate keeps the user from the whole app. A feature whose screen needs more than the rest of the app does, such as an account, asks for a condition on the route of that screen instead. A role publishes the condition, and the feature lists that role. This sketch is a feature with a profile screen for users with an account, which asks for AuthRole.account of the auth role:
ModuleDescriptor get descriptor => const ModuleDescriptor(
id: id,
description: 'A profile screen for users with an account',
kind: ModuleKinds.feature,
// The screen reads the session of the app, and its route asks for a
// condition of the role.
requires: {authRole},
);
List<Contribution> contribute(ModuleContext context) => [
BrickContribution(profileBundle),
routerRole.data(
const RoutesData([
Route(
'/',
name: 'profile',
screen: ScreenRef(
'ProfileScreen',
import: ImportRef.app('features/profile/profile_screen.dart'),
),
conditions: [AuthRole.account],
),
]),
),
];
- The routes below a route that asks for a condition ask for it too.
- The feature knows no module with a guard. In an app with a guard that stands for the condition, a request for the route opens the target of that guard over the page that the user is on, and the route shows once the condition holds. In an app without such a guard, the route shows like any other, and the auth role warns of it when the app is generated.
- Every user gets to the main navigation, to the screen that the app starts on and to the flow of a guard. So a route that asks for a condition is no destination and below none, neither it nor a route below it is a start candidate, and it is outside the flows of the guards of the module.
- When the condition stops holding, the router closes the page. A screen that ends the condition itself, as with a sign-out button, only ends it: it neither closes itself nor navigates.
A guard that stands for a condition
A module that requires or provides the role of a condition can stand for it, with a guard that names it in condition. The built-in sign_in declares a gate and such a guard, with one target:
guards: [
// After a sign-out, the next user starts on the screen that
// the app starts on.
RouteGuard(
name: gate,
allows: FunctionRef('signInAllowsApp', import: _guards),
redirectTo: _route,
stage: GuardStage.identity,
resumes: false,
),
RouteGuard(
name: accountGuard,
allows: FunctionRef('signInHasAccount', import: _guards),
redirectTo: _route,
stage: GuardStage.identity,
resumes: false,
condition: AuthRole.account,
),
],
The functions of the two guards are in a file of the feature, and each returns a value of the session of the app:
ValueListenable<bool> signInAllowsApp() => appSession.allowsApp;
ValueListenable<bool> signInHasAccount() => appSession.hasAccount;
- The function says whether the condition holds, and the target is the route that the router opens while it does not.
- The guard keeps the user only from the routes that ask for the condition, of whichever module.
sign_inasks for it itself on the route of its account screen, and a feature of yours that asks forAuthRole.accountgets the same guard. - A module declares at most one guard for a condition, and two modules with a guard for the same condition cannot be in one app.
- The guard may show the target of a gate of its module, as this one does. The two then have one flow, which is over once both allow, and their functions read one notifier, here the session, so that the router never finds that one of them changed and the other did not yet.
- Both guards ask who the user is, so they have the stage
GuardStage.identity, and neither brings the user back. - Until the condition holds, the routes of the flow show like any route. A screen may open the target with
push(), and a link to a route of the flow opens it over the screen that the app starts on. Inside the flow, its screens navigate withpush()orreplace(), which keep the request that opened the flow waiting.
Conditions of routes describes what the user sees, and Define a role how a role publishes a condition.
Texts, settings and notes
The screens of this page show their texts as literals, to keep the examples short. A feature gives its texts to the localization role, so that they follow the language of the app; see Texts. A feature can also have a setting that the app remembers and shows on its settings screen, as Add a setting shows, and a section in the guide for coding agents of the app, under a heading of its own; see Notes for coding agents.