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');
ModuleDescriptor get descriptor => ModuleDescriptor(
id: id,
description: 'A screen that counts taps',
kind: ModuleKinds.feature,
// The composition file resolves the store.
requires: const {diRole},
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.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 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 declares its routes as data of the router role:
routerRole.data(
const RoutesData([
Route(
'/',
name: 'counter',
screen: ScreenRef(
'CounterScreen',
import: ImportRef.app('features/counter/counter_screen.dart'),
),
destination: Destination(
label: 'Counter',
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 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.
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.
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;
- 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.