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 the whole of bloc:

final class BlocModule extends SmfModule {
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')];
}

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, 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 {
const EventBusModule();

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,
),
),
),
];
}

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.

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.

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.

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.
paramsUp to two types of values that the caller passes to a factory with resolveWith, after the dependencies.
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.
instanceNameA name that tells this service apart from others of the same type.

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.

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 routes = GoRoutes.of(
routerRole.facadeOf(input),
start: routerRole.startIn(input),
mainNavigation: input.has(layoutRole),
);
return RoleOutput(
vars: {
'initial_location': routes.initialLocation,
'main_navigation': routes.hasMainNavigation,
'routes': routes.routes,
'value_checks': routes.valueChecks,
},
);
}
}
  • 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.
  • 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 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 for a location of the main navigation pushed from a page over it, observers for every navigator, and the listeners of the screen;
  • the layout, LayoutRole: AppShell(destinations:, currentIndex:, onSelect:, body:) in lib/core/layout/app_shell.dart;
  • dependency injection, DiRole: createServiceLocator() and registerDependencies() in lib/core/di/dependencies.dart, the registrations in the order of DiGraph.ordered, and the capabilities of the container.

The contract harness checks that a provider generates the symbols its role requires and follows the rules of the role. See Test a module.