Skip to main content

Provide a role

A module provides a role with a provider object in its descriptor. What the provider has to do depends on the role. Some roles need only a package, and others give the provider their data to render. The examples come from the built-in modules.

A plain provider​

A module whose files and dependencies alone implement the role uses RoleProvider.plain. This is bloc, which adds its package and its note for coding agents:

final class BlocModule extends SmfModule {
/// Creates the module.
const BlocModule();

/// The id of the module, by which other modules key their variants for
/// BLoC.
static const id = ModuleId('bloc');


ModuleDescriptor get descriptor => const ModuleDescriptor(
id: id,
description: 'BLoC with flutter_bloc',
kind: ModuleKinds.infrastructure,
providers: [RoleProvider.plain(stateManagementRole)],
);


List<Contribution> contribute(ModuleContext context) => [
const PubspecContribution.hosted('flutter_bloc', '^9.1.1'),
AppEntryRole.agentSections.entry(
stateManagementRole.description,
AgentNote(agentNote),
),
];
}

A provider of the state management role brings its package with a constraint of its own, which makes the package belong to the provider. Features add it only in their variant for this provider, with the constraint any. The provider's id is the key of those variants, so choose it with care. riverpod also adds its ProviderScope to the root wrappers.

The packages of a provider​

A provider brings a package when it adds the package outside a variant, with a constraint other than any. The package then belongs to the provider, unless a module that the provider depends on brings it too. In an app with the provider, every other module that adds the package, with any constraint, must take it from the provider: in its variant for the provider, with any, or through a dependency on the provider. smf create reports any other module that adds it. By default it leaves that module out of the app, and with --strict it stops.

So bring only the packages that implement your role. A package that many modules add, such as collection, stays shared as long as no provider brings it, so add such a package with any. If your provider brought it with a constraint of its own, every module that adds it without a variant for your provider or a dependency on it would be reported in the apps that have your provider. Two providers that bring the same package, neither depending on the other, are both reported in an app that has both.

A module that imports the package of a provider adds the package too, even when it depends on the provider, so that the check above sees it. The contract harness reports code that imports or exports such a package without adding it. Your provider may import, for a fragment or in a file that its render hook generates, the package that a module which gives your role data adds, since the code of the data may need it.

A service: events, analytics, crash reporting​

The events, analytics and crash reporting roles generate the interface of their service and everything around it: the function that returns the app's service, a service that forwards every call to all providers when the role takes several, the registration in the DI container, and start-up code. A provider only implements the interface and contributes its implementation as data of the role. This is event_bus:

final class EventBusModule extends SmfModule {
/// Creates the module.
const EventBusModule();

/// The id of the module.
static const id = ModuleId('event_bus');

static const _file = ImportRef.app(
'core/events/event_bus_communication_service.dart',
);


ModuleDescriptor get descriptor => const ModuleDescriptor(
id: id,
description: 'Event bus with event_bus',
kind: ModuleKinds.infrastructure,
providers: [RoleProvider.plain(eventsRole)],
);


List<Contribution> contribute(ModuleContext context) => [
BrickContribution(eventBusBundle),
const PubspecContribution.hosted('event_bus', '^2.0.1'),
eventsRole.data(
const RoleImplementation(
type: TypeRef('EventBusCommunicationService', import: _file),
create: FactoryRef(
'createEventBusCommunicationService',
import: _file,
),
),
),
AppEntryRole.agentSections.entry(
eventsRole.description,
AgentNote(agentNote),
),
];
}

Its brick implements the interface of the role, which it imports from the role's file:

lib/core/events/event_bus_communication_service.dart
import 'package:event_bus/event_bus.dart';

import 'communication_service.dart';

CommunicationService createEventBusCommunicationService() =>
EventBusCommunicationService(EventBus());

final class EventBusCommunicationService implements CommunicationService {
EventBusCommunicationService(this._eventBus);

final EventBus _eventBus;


void fire(AppEvent event) => _eventBus.fire(event);


Stream<T> on<T extends AppEvent>() => _eventBus.on<T>();
}
  • A provider contributes exactly one implementation, and only a provider of the role contributes one. No module puts code into the socket of the implementations, which the template fills.
  • The function that creates the implementation takes no arguments, so the service works without a DI container. The role registers the service in the container when there is one.
  • An implementation that starts asynchronously uses RoleImplementation.async(type: ..., init: ...), whose function returns a Future. The role awaits it in the platform phase of bootstrap().
  • A crash reporter only reports. The template of the crash reporting role installs the handlers of errors, which present the errors as Flutter does, so the reporter prints nothing. A provider whose SDK installs handlers of its own turns them off.

What the services promise​

An events service delivers each event once to every listener of its type that is listening when the event is fired, in the order the events were fired. Whether it also delivers the event to the listeners of a type that the event extends or implements, such as on<AppEvent>(), is up to the provider, and event_bus does. A listener of any other type gets neither the event nor an error. The stream of on<T>() has only the events fired after it is listened to, so an event fired after on<T>() returned the stream, but before listen, is not in it. A cancelled subscription gets no more events.

An app can have several providers of analytics and of crash reporting, which know nothing of each other, so the service of the role calls each of them on its own. A provider that throws, or whose future fails, keeps no other provider from the call, and its failure reaches neither the code that called nor the handlers of the errors of the app. In debug mode the service prints it, so a provider that does not work shows in the console. Each analytics provider gets its own copy of the map of parameters, and one that changes the map changes nothing for the caller or for the other providers. A provider whose factory throws, or whose asynchronous start fails, is left out, and the app starts and works with the others.

SMF's CI checks these promises in running apps. The test of the events role runs in every app with the role in the matrix of the built-in modules. The tests of analytics and crash reporting run in an app with the Firebase providers of both roles next to fixture providers of the same roles.

Preferences​

The preferences role is a service role too: its template generates the AppPreferences interface, and a provider contributes one implementation of it. This is what shared_preferences contributes:


List<Contribution> contribute(ModuleContext context) => [
BrickContribution(sharedPreferencesBundle),
const PubspecContribution.hosted('shared_preferences', '^2.5.5'),
preferencesRole.data(
const RoleImplementation.async(
type: TypeRef('SharedAppPreferences', import: _file),
init: FactoryRef('openSharedAppPreferences', import: _file),
),
),
AppEntryRole.agentSections.entry(
preferencesRole.description,
AgentNote(agentNote),
),
];

The role does more with the implementation than create it. initPreferences() of the role opens the preferences with the function of the implementation and then gives them to the restorers of the modules, and bootstrap() awaits it in its platform phase, whether the implementation is created asynchronously or not. A test runs initPreferences() again to see what the next launch reads, so the function of the implementation reads what is saved each time it is called.

Every implementation keeps what the interface promises:

  • A read is synchronous, from memory, and never throws. It returns null when the key has no value of the type it asks for. Whether a number saved as an int is read as a double, or the other way round, is up to the provider.
  • Once the future of a write completes, reads return what it saved, in this run of the app and after its next launch.
  • A key has one value. A write replaces what the key had, a value of another type too, and remove makes the reads of the key return null again.
  • The preferences keep a copy of a list they are given, and a read returns a copy of it.

A package may promise less than the role, and the implementation then makes up the difference. The reads of the shared_preferences package cast the value and throw on another type, and the package keeps the list that it is given, so SharedAppPreferences checks the type of each value itself and hands the package a copy of each list.

SMF's CI checks these promises in running apps with every module that provides the role, and on devices, where the platform side of the provider is the real one; see Contributing.

Sign-in​

The auth role takes one implementation too: its template generates the AuthService interface, and a provider contributes its implementation of it. Unlike the other services, the code of the app never calls this one. It signs in through appSession, which the template generates and which does what is the same with every provider. The role registers nothing in a DI container. Provide sign-in describes what the service keeps to.

Following the router​

A provider can work with another role through the roles its role uses. The analytics role uses the router, so firebase_analytics gives the router a listener of the screen, only when the app has a router:

const SocketContribution.item(
RouterRole.screenListeners,
Fragment(
'logFirebaseScreenView',
imports: [
ImportRef.app(
'core/analytics/firebase_analytics_service.dart',
show: ['logFirebaseScreenView'],
),
],
),
when: {routerRole},
),

The listener is a function of the module's own file, which exists only in an app with a router:

{{#has_router}}
void logFirebaseScreenView(String? route, String location) {
final name = route ?? (Uri.parse(location).path == '/' ? '/' : null);
if (name == null) return;
FirebaseAnalytics.instance.logScreenView(screenName: name).catchError(
(Object error) => debugPrint('Firebase Analytics: $error'),
test: (error) => error is PlatformException,
);
}{{/has_router}}

A listener has the type void Function(String? route, String location). It follows the screen the user sees, which is the page of the router on top of the app. Pages that the app shows past the router, such as with Navigator.push, and dialogs are not pages of the router. Every provider of the router promises to call each listener once for each change of that screen:

  • when the app shows its first screen;
  • when another page comes on top: a page that a navigation shows, a page that shows again as the pages above it close, or the page of the tab, or other branch of the main navigation, that the user selects;
  • when the page on top shows another location, such as the same route with other values of its parameters.

The router does not call a listener for the pages that a navigation puts below the one on top, such as the parents of a route that go() shows, and never twice in a row for the same page at the same location. So a listener hears of the back button of the system only when the button closes a page of the router: when it closes a dialog, the page below stays the screen the user sees.

The listener gets the full name of the route, such as home.home, and the location with its query and fragment. For a screen that is not a route of a module, the route is null and the location is still that of the screen: / for the fallback start screen, or the location that the router could not show for its error screen. The router may call a listener while the app builds, so a listener only takes note of the screen. It does not navigate, rebuild widgets or throw.

The router calls each listener on its own, since the modules of the listeners know nothing of each other. A listener that throws anyway keeps no other listener from hearing of the screen, and what it throws reaches neither the router nor the handlers of the errors of the app. In debug mode the router prints it, so a listener that fails shows in the console.

RouterRole.observers takes factories of NavigatorObservers instead, which a router calls once for each navigator it creates. An observer sees the pages of one navigator, not the screen the user sees, so use the listeners to follow screens.

Registering services​

A module that creates services of the app registers them as data of the DI role with diRole.data(DiRegistration(...)), and the provider of the role renders them in the form of its container:

FieldMeaning
typeThe type the service is registered as, usually an interface, with the import that declares it.
createThe top-level function that creates it. Its deps are the services it takes, which the container passes in that order.
lifetimelazySingleton, the default, is created on first use. singleton is created while the services are registered, and factory makes a new one on every use.
isAsyncThe function returns a Future. Only a singleton can be created asynchronously, and registerDependencies() waits for it.
dependsOnServices that a singleton must wait for although it does not take them.
disposeA top-level function that takes the instance of a singleton or lazy singleton and disposes of it when resetDependencies() resets the container.
instanceNameA name that tells this service apart from others of the same type.

A value known only at run time, such as the id of the item that a screen shows, is not part of a registration. A function in the composition file of a feature takes it as a parameter, as in DetailsCubit createDetailsCubit(int id). Elsewhere, a factory class that takes its services in its constructor creates the objects with such values in a method such as call(int id). It is registered like any other service, and other services take it in their deps.

The DI role checks the registrations of the app before anything is generated. A service registered twice is an error, and so are a dependency that nobody registers, a cycle, and a singleton that waits for a service that is neither created asynchronously nor waits itself. The fields beyond the basics need capabilities of the container, which each provider declares, and get_it has them all.

The provider of the role renders every registration: its files call or tear off the factory of each registration, and its dispose function if it has one. The contract harness checks this in the apps of every provider. So the tests of a module that registers services read the registrations with diRole.graphOf, not in the files of a provider; see Test a module.

The provider also declares Future<void> resetDependencies() in lib/core/di/dependencies.dart, next to createServiceLocator() and registerDependencies(). It disposes of the services that the container created, each with its dispose function, in the reverse order of their registration. Then it removes every service, so that registerDependencies() can register them again. It creates nothing just to dispose of it, so a lazy singleton that nothing resolved is neither created nor disposed of. The app itself never calls resetDependencies(), but its tests can, such as a test that registers the services again.

SMF's CI runs the start-up of the apps of every provider of the DI role and checks that every registered service resolves, a singleton or lazy singleton to the same instance each time. It also checks that none resolves after resetDependencies() and that all of them resolve again after registerDependencies(). The services of the fixtures note what their dispose functions do, so CI checks the order of the disposal too.

A provider that renders its data​

A role with data gives it to its provider, which turns it into code. go_router renders the routes of all features into the variables of its brick:

final class _GoRouterProvider extends RoleProvider<RoutesData> {
const _GoRouterProvider();


Role<RoutesData> get role => routerRole;


RoleOutput render(RoleHookInput<RoutesData> input) {
final facade = routerRole.facadeOf(input);
final routes = GoRoutes.of(
facade,
start: routerRole.startIn(input),
mainNavigation: input.has(layoutRole),
);
return RoleOutput(
fragments: [
if (routes.hasMainNavigation)
AppEntryRole.agentSections.entry(
routerRole.description,
AgentNote(mainNavigationAgentNote),
)
else if (input.has(layoutRole))
AppEntryRole.agentSections.entry(
routerRole.description,
AgentNote(firstDestinationAgentNote),
),
],
vars: {
'initial_location': routes.initialLocation,
'main_navigation': routes.hasMainNavigation,
'routes': routes.routes,
'main_navigation_route': routes.mainNavigation,
'value_checks': routes.valueChecks,
// Whether the modules of the app declare guards, which the router
// then asks.
'guards': facade.guards.isNotEmpty,
},
);
}
}
  • render gets the data of the role in the order the modules were chosen, with the module that contributed each, and the data of the roles its role requires or uses. input.has(role) tells whether such a role is present. The role offers typed views of its data, such as routerRole.facadeOf(input), the routes with their full paths and names, which the provider shares with the facade, so both agree on every path and name.
  • RoleOutput.vars are variables of the provider's bricks. A variable can be a Fragment of code with imports, such as the routes, which a template reads as {{{routes}}}, and the pipeline adds its imports to that file. See Templates.
  • RoleOutput.fragments put code into sockets, with the same access rules as the module's contributions. Here they are the notes of the module for coding agents that depend on the routes of the app; see Notes for coding agents.
  • RoleOutput.files are text files of the provider, each by its path in the app. A brick has the same files in every app, so a file whose path depends on the data of the role, such as one file for each item that the modules give the role, comes from the hook. See Files of render hooks.
  • validate checks the data against what the provider supports and returns problems. LayoutProvider does it for the number of destinations, and bottom_tabs sets maxDestinations to 5.

The documentation of each role on pub.dev says what it expects of its providers, meaning the files and symbols they generate and the behavior they promise:

  • the app entry, AppEntryRole: main() in lib/main.dart, which initializes the binding, awaits bootstrap() and runs the root widget inside the root wrappers; bootstrap() in lib/bootstrap.dart, with the phases of start-up in their order and without a design library; the root of the app as a MaterialApp, created in the build(BuildContext context) of a widget, so that the arguments that modules give the root read the context below the wrappers; the fallback start screen; and the native projects, with the tags of the native sockets;
  • the router, RouterRole: createAppRouter() in lib/core/router/app_router_factory.dart, routes named by their full names, the start route at /, the main navigation around the layout's AppShell, the StateError of push() and replace() with a location of the main navigation on a page over it, a config created once, the back button of the system, on its error screen too, push() and replace() on that screen, observers for every navigator, the listeners of the screen, each called on its own, and the guards of an app that has them;
  • the layout, LayoutRole: AppShell(destinations:, currentIndex:, onSelect:, body:) in lib/core/layout/app_shell.dart, with destinations, currentIndex and onSelect as public fields or getters;
  • dependency injection, DiRole: createServiceLocator(), registerDependencies() and resetDependencies() in lib/core/di/dependencies.dart, the registrations in the order of DiGraph.ordered, a call or a tear-off of the factory of every registration and of its dispose function, and the capabilities of the container;
  • the settings screen, SettingsScreenRole: a route of the provider that shows the screen, with every entry of the modules once, in the order of the role;
  • localization, LocalizationRole: the extension AppTexts on BuildContext in lib/core/l10n/l10n.dart, with a getter for each text of the app in each of its languages;
  • the theme, ThemeRole: createLightTheme(context) and createDarkTheme(context) in lib/core/theme/app_theme.dart, the look of the app;
  • sign-in, AuthRole: one implementation of AuthService of lib/core/auth/auth_service.dart, the same in every mode of the app, whose calls fail only with an AuthFailure; see Provide sign-in.

When the user presses the back button of the system, a router closes the route on top. It tries NavigatorState.maybePop on the navigators that the user sees, the innermost first: a dialog closes before the page below it, and a page of the selected branch of the main navigation before the main navigation, unless a route of the root navigator, such as a dialog, covers the main navigation. With no route to close, the router leaves the button to the system, which closes the app. A press that closes a route is one that the router handled. Otherwise the system takes the button too, and closes the app together with the route.

The error screen of a router is a page like any other for the button. A dialog over it closes, and the button throws nothing there. The screen itself closes only if a page is below it, which is up to the provider. With none, the router has no route to close and leaves the button to the system.

On its error screen, a router still shows the location that push() or replace() asks for, and the listeners of the screen hear of it once. replace() shows it in place of that screen. push() shows it over that screen and completes with the value of its page. A provider that has no page there to push over shows it in place of that screen, as go() does, and completes the push with null at once.

The layout role generates lib/core/layout/destination.dart with the class Destination and with appDestinations, the destinations of the app in the order of the features. The label of a Destination is a function of a BuildContext, which returns the text of its module in the language of the app, or its English text. So the two providers share the work like this:

  • A router gives the shell LayoutRole.appDestinations as its destinations and has a branch for each, in the same order. It renders neither a label nor an icon. The contract harness checks that, in an app with a layout and destinations, the files of the router read appDestinations (the rule router.destinations_shown).
  • A layout shows a label with destination.label(context) in the build of the widget that shows it, as a text, in a tooltip or to the semantics, and keeps nothing of what it returned. The widget then builds again with the new label when the language changes.

A layout keeps destinations, currentIndex and onSelect of its AppShell public because code that knows only the role reads them, whichever layout provides it. A test of the main navigation does this: it finds a destination among destinations by its label and icon, selects it with onSelect, as the layout does when the user selects it, and reads the selected one from currentIndex. The body may stay private, since such code finds what the shell shows in the widget tree.

SMF's CI checks the router and the layout in running apps, with every provider of the two roles that SMF has. One test starts the app and goes with go() to each location that needs no values, up to the first 20. Each location must show the page of its route on top of the innermost navigator, named after the route, and the screen of the route, with no ErrorWidget and no error from Flutter. Other tests check the listeners of the screen, the observers, the back button, also on the error screen, push() and replace() from that screen, the StateError of push() and replace(), the config created once, the guards and the conditions of the routes, the switches between the destinations of the layout, and that the layout shows no label in a language that the app is no longer in. Contributing lists these tests.

Roles with a page of their own​

Four roles, and the guards of the router role, ask more of a provider than fits here: