Skip to main content

Write an infrastructure module

An infrastructure module sets up a library or a service without screens of its own. It declares no routes, keeps nothing in lib/features/, has no variants, and never takes services from the DI container itself. It puts its code and settings into the app entry, which every module may use, and into the sockets of the roles and modules it declares.

The examples on this page come from two small modules that were generated into an app and checked with flutter analyze: logging, which sets up the logging package, and git, which makes the new app a Git repository.

Start-up code​

bootstrap() runs the start-up code of all modules in four phases: early, platform, DI and late. A module adds a fragment of code to a phase, with the imports it needs:


List<Contribution> contribute(ModuleContext context) => [
const PubspecContribution.hosted('logging', '^1.3.0'),
const SocketContribution.code(
AppEntryRole.bootstrapEarly,
Fragment(
r"Logger.root.onRecord.listen((record) => debugPrint('${record.level.name}: ${record.message}'));",
imports: [
ImportRef('package:flutter/foundation.dart', show: ['debugPrint']),
ImportRef('package:logging/logging.dart', show: ['Logger']),
],
),
),
];

The pipeline adds the imports to lib/bootstrap.dart, merges them with those of other modules, and formats the file:

lib/bootstrap.dart
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:logging/logging.dart' show Logger;

Future<void> bootstrap() async {
Logger.root.onRecord.listen(
(record) => debugPrint('${record.level.name}: ${record.message}'),
);
}

Pick the phase by what the code needs: early for what must come first, platform for platform services such as Firebase.initializeApp, DI for the registration of the services, and late for code that needs the services. Code that must be declared at the top level of lib/bootstrap.dart, such as a handler of background messages, goes into AppEntryRole.topLevel. lib/bootstrap.dart imports neither material nor cupertino, so start-up code does not depend on a design library.

Wrappers around the app​

AppEntryRole.rootWrappers wraps the root widget in main(), and AppEntryRole.appBuilder wraps the content of every route, in the builder of the root MaterialApp. A wrapper has an opening and a closing part. This is how riverpod adds its ProviderScope:

const SocketContribution.wrap(
AppEntryRole.rootWrappers,
Fragment.wrap(
'ProviderScope(child: ',
')',
imports: [
ImportRef(
'package:flutter_riverpod/flutter_riverpod.dart',
show: ['ProviderScope'],
),
],
),
),

The first contribution is the outermost wrapper, as the order of contributions decides.

AppEntryRole.appArgs sets arguments of the root MaterialApp: theme, darkTheme, themeMode and locale, which take one value, localizationsDelegates, which takes the list items of every contributor, and supportedLocales, which takes the list items of one contributor. The root is created in the build of a widget below the root wrappers, so the expression of an argument may read context. An inherited widget among the root wrappers can give the root a value this way, and the root rebuilds when the widget notifies. The theme role gives the root its mode like this:

const SocketContribution.wrap(
AppEntryRole.rootWrappers,
Fragment.wrap(
'AppThemeModeScope(notifier: appThemeMode, child: ',
')',
imports: [_themeMode],
),
),
// ...
const SocketContribution.arg(
AppEntryRole.appArgs,
'themeMode',
Fragment('AppThemeModeScope.of(context)', imports: [_themeMode]),
),

That context is above the MaterialApp, so it has neither the theme nor the localizations of the app. Two rules of the app entry role make it the same with every module that provides the app entry. The tags of the root wrappers are in the body of main(), which runs the root widget inside them (app_entry.root_wrappers_in_main). And the provider creates the root MaterialApp in a method build(BuildContext context) of a class (app_entry.root_in_build).

Two different values of an argument that takes one are an error that names both contributors, and smf create stops. The items of a second contributor of supportedLocales are a conflict too, also when they are the same items: every delegate of the root has to support each of its locales, so a locale that one contributor adds would break the delegates of another. smf create leaves the module of those items out with a warning, or stops with --strict. In an app with the theme role, the role sets theme, darkTheme and themeMode, and in an app with the localization role, the role sets locale and is the one contributor of supportedLocales. So a module leaves these arguments to the roles: a theme comes from a module that provides the theme role, and a module gives its texts to the localization role instead of bringing a delegate of its own.

Dependencies​

PubspecContribution adds to the pubspec.yaml of the app:

  • PubspecContribution.hosted('logging', '^1.3.0') depends on a package of pub.dev, and dev: true makes it a dev dependency.
  • PubspecContribution.sdk('flutter_localizations') depends on a package of the Flutter SDK.
  • PubspecContribution.flutter(assets: [...], fonts: [...], generate: true) adds to the flutter: section.
  • PubspecContribution.environment(sdk: ..., flutter: ...) constrains the Dart and Flutter versions of the app.

The pipeline intersects the constraints of a package that several modules add, and an empty intersection is an error. Declare environment only when your package needs a newer Dart or Flutter than the app has anyway. flutter_core sets Dart ^3.12.0 and Flutter >=3.44.0, and smf create checks the Flutter SDK against the merged constraints before it generates anything.

A package that the provider of a role brings, such as flutter_riverpod of riverpod, is that provider's. Another module adds it only in its variant for the provider, with the constraint any, or when it depends on the provider. A package that no provider brings is shared, and every module that needs it adds it. A provider brings a package when it adds the package outside a variant with a constraint other than any. In a provider, add a shared package, such as collection, with any. See The packages of a provider.

Native settings​

The app entry has sockets for the native files, so a module never edits them:

AppEntryRole.iosDeploymentTarget.value('16.0'),
AppEntryRole.androidManifestPermissions.key('android.permission.INTERNET'),
AppEntryRole.androidManifestApplicationMeta.entry(
'com.example.sdk.CHANNEL',
const AndroidMetaData.value('stable'),
),
AppEntryRole.infoPlist.entry(
'UIBackgroundModes',
const PlistStringArray(['fetch']),
),
AppEntryRole.gradleSettingsPlugins.entry('io.github.ben-manes.versions', '0.64.0'),
AppEntryRole.gradleAppPlugins.key('io.github.ben-manes.versions'),
AppEntryRole.gradleAppDependencies.entry('androidx.annotation:annotation', '1.9.1'),
AppEntryRole.androidCoreLibraryDesugaring.value('2.1.4'),
...AppEntryRole.paidTeamEntitlement(
'com.apple.developer.applesignin',
const PlistStringArray(['Default']),
),
const SocketContribution.code(
AppEntryRole.iosAppDelegateLaunch,
Fragment(' UNUserNotificationCenter.current().delegate = self'),
),
const SocketContribution.code(
AppEntryRole.androidManifestApplicationComponents,
Fragment('''
<receiver
android:exported="false"
android:name="com.example.sdk.AlarmReceiver" />'''),
),
  • The minimum iOS version is the highest that any module asks for, and flutter_core gives 15.0.
  • Equal entries of several modules merge, and different values of one key are an error that names both modules. Arrays of strings in Info.plist and in the entitlements are united, and Gradle versions take the highest.
  • A key that the template of flutter_core has already, such as CFBundleName, cannot be added again. The Gradle sockets do not take the plugins that the template declares or applies, or the Kotlin plugin, which the Flutter Gradle plugin applies itself.
  • AppEntryRole.mainActivityIntentFilters takes <intent-filter> elements of the main activity as XML, such as deep links.
  • androidCoreLibraryDesugaring takes the version of desugar_jdk_libs that a library of the app asks for, when the Android build of an app with that library fails without desugaring. The highest version wins, and the one value turns desugaring on in compileOptions and adds the library to dependencies. The build fails with either line alone.
  • iosEntitlements takes the entitlements of the iOS app into ios/Runner/Runner.entitlements. On a device, the capability of an entitlement may need a team of the paid Apple Developer Program. The simulator signs the app without one. An entitlement that only such a team signs, such as the one of Sign in with Apple above or aps-environment for push messages, goes in with AppEntryRole.paidTeamEntitlement, which also lists the step by hand in which the developer signs the app with that team; see a step of a role.
  • iosAppDelegateLaunch takes Swift statements, indented by four spaces, that run in application(_:didFinishLaunchingWithOptions:) before the launch goes to Flutter. They read the app delegate as self, and the parameters application and launchOptions, and take no imports: the file imports Flutter and UIKit, and Flutter brings UserNotifications. Flutter registers the plugins of the app once its scene connects, after this method, so the statements have no registry of plugins and no channel.
  • androidManifestApplicationComponents takes <receiver>, <service> and <provider> elements as XML, indented by eight spaces, for a component that the app has to declare itself, such as a receiver that the manifest of a library leaves out. Declare each component once. The contract harness reports two declarations of one android:name, also from two modules, and reads a name that starts with a dot, or has none, against the namespace of the app, as Android does. Android fails to build an app whose two declarations of a component differ.

local_notifications uses three of these sockets: desugaring, the receivers of its package with the permission to start after a restart of the device, and the delegate of the notification center in the AppDelegate.

README sections​

AppEntryRole.readmeSections adds a section to the README.md of the app, under a heading of its own, in Markdown:

AppEntryRole.readmeSections.entry(
'Logging',
'The app logs with the [logging](https://pub.dev/packages/logging) '
'package: create a `Logger` per class. `bootstrap()` prints the '
'records at `Logger.root.level` and above, which is INFO by default.',
),

Use it for what someone who opens the app needs to know about your module, such as how to set up a service again. firebase_core explains there how to configure Firebase. The heading "Finish the setup" is taken: a step that only the developer can do is a step by hand, which the app entry role lists in that section.

Notes for coding agents​

Every app has a guide for the coding agents that work on it, AGENTS.md. AppEntryRole.agentSections is the socket of its sections, and a module adds a note to it under a heading, in Markdown:

static const _agentNote = '''
- For the feedback of a tap, call `tapFeedback()` of `lib/core/haptics/haptics.dart`. Do not call `HapticFeedback` yourself: the user can turn the feedback off, and only `tapFeedback()` asks.
- Change the choice only with `chooseHaptics()`, which also saves it when the app has preferences.
''';
AppEntryRole.agentSections.entry('Haptics', AgentNote(_agentNote)),

A note tells what the code of the app does not show: a rule that holds across files, an order, what not to do and what to do instead, a placeholder, a step that needs a person. What the code shows needs no note, and setup that needs a person or an account goes into a README section. The built-in modules keep the texts of their notes in lib/src/agents.dart of their package.

The heading of a note​

  • The template of a role writes the note of the role under the description of the role, such as routerRole.description: where the files of the role are, how code uses it, and what code must not do. Such a note holds whichever module provides the role, so it never names a provider or its package. Only a template contributes it, with AgentNote.ofRole, and the socket refuses one from a module; see Define a role.
  • A module that provides a role adds its note under the same heading, with AgentNote, and the section has the note of the role first. A provider that wraps a package opens its note with a line that names the package, such as "With go_router:". Then it tells where it writes what the role describes, and how to add one more by hand. A provider that wraps a tool of Flutter names the tool instead, as gen_l10n does with "With gen-l10n of Flutter:".
  • A module without a role uses a heading of its own, as haptics does above.

The notes under one heading are united into one section: the notes of roles first, then the others in the order of the ids of their contributors, each once. The section of the app entry comes first in the guide, and the others follow in the order of their headings. So replacing the provider of a role changes only its own note.

The rules of a note​

  • It is true in every app that has it. A note about a file that only some apps have goes into a contribution of its own, with the role of that file in its when. firebase_analytics adds its note on screen views with when: {routerRole}, since only an app with a router has the listener that logs them.
  • What depends on the data of a role comes from the render hook of the role or of the module that provides it, as a fragment for the socket. go_router tells this way where the destinations of the main navigation go, only in an app that has one, and the router role adds what it tells of the guards only in an app with guards.
  • It stays in its section. It has no line that starts a title or another section, with # or ## or with a line of = or - under a line of text, and it closes its fenced code blocks. A heading is one line.
  • It names a file or a directory of the app in backticks, by its path from the root of the app, such as lib/core/haptics/haptics.dart. A file that a later step writes, such as one of a tool that runs after generation, is named as a pattern, with <…> or *, or left to the README.

What is checked​

In every app that the contract harness renders, the rule app_entry.agent_guide_paths reads the paths that the guide names in backticks below a top-level directory of a Flutter project, such as lib/, test/ or android/, and its paths to Dart files. It reports one that the app does not have. It does not read the name of a file at the root of the app, such as pubspec.yaml.

The socket puts its sections in an order of its own, so a when of a note adds nothing to the order of contributions, and no note can make an app impossible to generate.

A test can also hold the notes of the roles against the modules that provide the roles, with roleNoteProblems: see The notes of roles.

Nothing checks what a sentence of a note says about the code, which only a test of that code can pin. A test of the module can pin the names: it reads the note from the app, and checks that each name of code that the note gives is one that a file of the app declares or uses. Test a module shows how a test reads the notes.

Building on another module​

When your module builds on another module itself, and not on a role, depend on it with the id constant of its package:

dependsOn: {FirebaseCoreModule.id},

The other module comes into the app with yours, and the constant makes its package a dependency of yours. Your module may then put code into the sockets of that module. A module can declare sockets of its own, such as SocketRef.module(id, 'setup', CodeSocket()), which only the modules that depend on it directly may fill. Prefer a role whenever one describes what you need, so that the user can pick its provider.

Code generation​

A module that needs build_runner contributes a CodegenRequest, and the builders it needs as dev dependencies:

const PubspecContribution.hosted('json_annotation', '^4.9.0'),
const PubspecContribution.hosted('json_serializable', '^6.9.0', dev: true),
const CodegenRequest(description: 'JSON code of FixtureModel'),

However many modules ask, the pipeline runs dart run build_runner build --force-jit once, after flutter pub get, and adds build_runner to the dev dependencies itself. A library that the builders generate and that code of the app imports, such as lib/core/di/dependencies.config.dart, goes into outputs, so that the pipeline and the harness know about it. Part files, such as .g.dart, need no mention.

Checking the machine​

A module that needs a tool on the machine contributes a Preflight with checks. A check has an id, unique in its module, and a description, which the report of --explain and the warnings show:

/// Checks that Git is installed.
final class GitCheck extends PreflightCheck {
const GitCheck();


String get id => 'git';


String get description => 'Git';


Future<PreflightStatus> check(SmfEnvironment environment) async {
if (await environment.findExecutable('git') != null) {
return const PreflightPassed();
}
return const PreflightMissing(
instructions: 'Install Git from https://git-scm.com/downloads.',
);
}
}
  • check only reads the machine, because --explain runs it too. It does not install anything, log in, ask the user or change files other than its temporary files. It returns PreflightPassed, PreflightMissing with instructions, or PreflightFailed when the check itself could not run.
  • When the check finds something other than what it looks for, such as an older version of the tool, it says what it found in PreflightMissing(found: ...), as a clause such as 'git 2.20.0 is installed'. The pipeline then says that the tool is needed, but git 2.20.0 is installed, rather than that the tool is missing.
  • A missing tool that the check can install is PreflightMissing(installable: true). In a terminal, unless the run skips external setup, the pipeline asks the user, showing what the check found and its instructions, calls install, and runs check again. install returns a ToolInstall with the directories of what it installed, which the pipeline adds to the PATH of later checks and commands.
  • A failed check is a warning by default. Set required only when the app cannot work without the tool. A failed required check leaves the module out of the app, or stops the run with --strict or when no app can be made without the module.
  • Never show the output of a command that can hold a secret, such as a token of a login. The login check of firebase_core reports only the error of the command.
  • Run commands with the processRunner of the environment, and find them with findExecutable, which also looks in the directories of tools installed during the run. The names flutter and dart stand for the Flutter SDK that the pipeline checked. On Windows, a batch file such as dart.bat cannot take arguments with ^, <, >, %, &, | or ", because cmd.exe would read them as its own.
  • A command that may wait a long time, such as one that asks a service over the network, gets a timeout in processRunner.run. The runner then stops the command along with the processes it started, and the result says so with timedOut. The login check of firebase_core gives firebase projects:list 40 seconds.
  • The pipeline shows the progress "Checking the machine" while the checks run, and another progress for a check that runs again after an installation, so a check does not need to show one itself.
  • Read an environment variable of smf with environmentVariable, such as SSH_CONNECTION, which tells the login check of firebase_core that the session is over SSH. On Windows, the name may have any case.

See the checks of firebase_core for checks that install tools and log in.

The order of the checks​

The pipeline checks the machine for a module after the modules it depends on, directly or not. It does not matter where the user named those modules, or whether the pipeline added them. The checks of the other modules keep the order of the modules of the app, and those of the templates of the roles come last.

After an installation, the pipeline runs again only the checks after it that have not passed. So a check of your module may need a tool that a check of a module it depends on installs. It cannot count on a check of any other module, whose place depends on the order in which the user named the modules.

A check from the module you depend on​

A step names only checks of its own module in needs. So when your module needs more of a tool than the module it depends on checks, that module can offer you a check to put into your own Preflight. smf_firebase_core does: for a module that depends on it and runs a command that only newer versions of the Firebase CLI have, it exports FirebaseCliVersionCheck.

const version = FirebaseCliVersionCheck(minimum: '15.34.0');

const Preflight([version]),
PostGenStep(tool, arguments, skippable: true, needs: [version.id]),

smf_firebase_core knows how to ask the Firebase CLI, and your module knows the number: the lowest version that has its command, as three numbers. firebase_auth asks for 15.34.0 this way, the first Firebase CLI that enables sign-in methods without adding a web app to a project that has the apps of flutterfire configure.

  • The check reads firebase --version as the check of the Firebase CLI of firebase_core runs it. It accepts that version and any later one, of a later major version too. A pre-release of the lowest version comes before it.
  • For an older Firebase CLI, it tells which command it found, in which version, and how to update it. It installs nothing: other projects of the user run the Firebase CLI too, so the user updates it.
  • On a machine without the Firebase CLI, it reports it missing, and the check of firebase_core offers the installation. That check runs first, so after the installation the check of the version runs again and reads the version that was installed.

Commands after generation​

A PostGenStep runs a command in the new app, after flutter pub get and code generation:

const Preflight([GitCheck()]),
PostGenStep(
ToolRef('git'),
['init', '--quiet'],
description: 'Creating a Git repository',
skippable: true,
needs: ['git'],
),
✓ Getting the packages of the app (1.0s)
✓ Creating a Git repository (0.0s)
✓ Cleaning up the imports (1.9s)
OptionMeaning
descriptionWhat the step does, for its progress and for the instructions when it is left for later.
interactiveThe command talks to the user, so it runs with the terminal attached, and only in a run that can ask.
externalThe command needs something outside the app, such as an account, so a run with --skip-external-setup leaves it for later.
skippableThe app is complete without it, so the pipeline may leave it for later, printing its command, instead of failing. In a run that can ask, the pipeline asks before it runs the step, so the user can leave it for later too.
needsIds of checks of the module's Preflight. When one has not passed, the step does not run, is not asked about, and is left for later with the check as the reason.
idThe id by which other steps continue this one, a PostGenStepId of the module and a name. See Continuing a step.
followUpOfThe id of the step that this one continues.
noticeWhat the user has to know before the step runs and its description does not tell, such as a side effect of the tool that it runs. The step then runs only once the user agreed to it. See A step with a notice.

A step that is not skippable fails the generation when it fails or cannot run, unless it continues a step that is not done. ToolRef names the executable, arguments that always come first, and environment variables. firebase_core, for example, runs a script of the app as ToolRef('dart') with the arguments [configureScript, AppEntryRole.defaultFlavor.name].

When a run ends, it reports the steps that are not done. A step that could not run or failed is a warning. A step that was left for later is in a list after the line that the app is created, with its description, the reason, its command and its notice. The machine and external setup has an example of each.

The step runs in the temporary directory of the app, which then moves to its place, so it must not write the absolute path of its working directory into the app. The pipeline writes the files where Flutter records such paths again in the app's directory.

Continuing a step​

A step can continue another step, such as with a tool that needs what that step set up. The module of the step to continue gives it an id and publishes the id as a constant of its module class, next to its ModuleId. firebase_core does this for the step that configures Firebase, which has id: configureStep:

final class FirebaseCoreModule extends SmfModule {
static const id = ModuleId('firebase_core');
static const configureStep = PostGenStepId(id, 'configure');
// ...
}

firebase_auth depends on firebase_core, and continues that step with the script that enables the sign-in methods in the project that the user chose:

PostGenStep(
const ToolRef('dart'),
const [enableSignInScript],
followUpOf: FirebaseCoreModule.configureStep,
description: 'Enabling the sign-in methods of the app in its Firebase project',
notice: enableSignInNotice,
skippable: true,
external: true,
needs: [firebaseCliWithSignIn.id],
),

A step that continues another is part of it:

  • It runs right after the step it continues, once that step succeeded, rather than in its own place among the steps. The user is not asked about it, unless it has a notice.
  • When the step it continues does not run or fails, it does not run either. It is left for later after that step, with that step as the reason, whether it is skippable or not.
  • It applies to the app whenever the step it continues does, so it has no when of its own. Otherwise it runs by its own options, such as needs and skippable.
  • Another step can continue it in turn, and then runs right after it.

A module continues its own steps and the steps of the modules it depends on directly. So the module of a step never learns which modules continue it. The pipeline reports as problems of the module a step whose id names another module, two steps with one id, a step that continues a step it may not continue or a step that the app does not have, a step that continues another and has a when, and steps that continue each other in a cycle.

A step runs once, when the app is generated. For what has to follow every later run of a tool too, the app needs a place of its own, which a module gives it as a script of the app. tool/configure_firebase.dart of firebase_core is such a script: it runs each Dart file of tool/firebase_configured/, in the order of their names, after each flavor that it configures. So firebase_crashlytics, which has to fix a file that flutterfire writes again in each of its runs, puts a brick file there and has no step, and firebase_core knows nothing of it. The file has only the libraries of Dart, since dart cannot load a file that imports Flutter.

A step with a notice​

Some steps do more than their description says, such as a tool that also changes something in an account of the user. A step tells the user of that in its notice, and then runs only once the user agreed to it. firebase_auth has such a step. It continues the step of firebase_core that configures Firebase, and enables the sign-in methods of the app in the Firebase project with a script of the app. For a way to sign in of Google, the script also changes the project and files of the app, which the description of the step does not tell:

const enableSignInNotice = 'If the app signs in with Google, it also gives '
"Google the app's name and the Firebase CLI account's email for its "
'consent screen, registers the SHA-1 and SHA-256 of this '
"machine's Android debug key (creating the key if there is none, as the "
"first debug build would), downloads the app's Firebase files again and "
'writes the iOS URL scheme into ios/Runner/Info.plist.';

A step cannot tell what the roles of the app chose, since the pipeline needs the steps before any role chooses, and a step that continues another takes no condition at all. So the step and its notice are the same in every app, and the notice says "if the app signs in with Google", which is true of every app.

final PostGenStep enableSignIn = PostGenStep(
const ToolRef('dart'),
const [enableSignInScript],
followUpOf: FirebaseCoreModule.configureStep,
description: 'Enabling the sign-in methods of the app in its Firebase '
'project',
notice: enableSignInNotice,
skippable: true,
external: true,
needs: [firebaseCliWithSignIn.id],
);

The step needs firebaseCliWithSignIn, the module's FirebaseCliVersionCheck(minimum: '15.34.0'), and the question of a run in a terminal is one line:

Enabling the sign-in methods of the app in its Firebase project (dart tool/enable_firebase_sign_in.dart), for firebase_auth. If the app signs in with Google, it also gives Google the app's name and the Firebase CLI account's email for its consent screen, registers the SHA-1 and SHA-256 of this machine's Android debug key (creating the key if there is none, as the first debug build would), downloads the app's Firebase files again and writes the iOS URL scheme into ios/Runner/Info.plist. Run it now?

What a run does with such a step:

The runWhat happens to a step with a notice
It can ask, and the step can run.It asks about the step, with the notice in the question: <description> (<command>), for <module>. <notice> Run it now?. Enter answers yes, as for every step. For a step that continues another, the question comes once that step succeeded.
It can ask, and the step cannot run.It does not ask, and leaves the step for later like any other. That is a step that is external in a run that skips external setup, one that needs a check which has not passed, one whose tool is missing, and one that continues a step that is not done.
It cannot ask.It leaves the step for later, although it runs a step without a notice unasked.

When the user declines a step, the steps that continue it are left for later after it, without a question.

The notice follows the command of a step that is left for later, whatever the reason, and --explain shows it under the command. So write it as a sentence or two on one line that read on their own in each of these places, without a reference to the question, such as "answer no". The pipeline reports a notice that is blank or has a line break.

The user may decline the step, so a step with a notice is skippable, and the pipeline reports one that is not.

Steps by hand​

Some of the setup of an app is out of reach of any command, such as uploading a key to the Firebase console or signing the iOS app with a paid Apple Developer team. A module tells of such a step with a ManualStep: an id and a short title, and nothing else. How to do the step, click by click, is in its guide, on a page that the id names.

firebase_messaging publishes its step as a constant of its module class, next to its ModuleId, and lists it among its contributions, since every app of the module needs it:

final class FirebaseMessagingModule extends SmfModule {
static const id = ModuleId('firebase_messaging');

static const apnsKeyStep = ManualStep(
ManualStepId(id, 'apns_key'),
'Give Firebase your Apple push key (APNs key)',
);
// ...
}

An app with steps by hand gets them in four places:

  • At the end of smf create, after the steps that are left for later, the line Finish the setup by hand in 2 steps:, or in one step:, with the link to their guide on a line of its own. The run asks nothing about them, so a run without a terminal prints the same line. See steps by hand.
  • In smf create --explain, the section By hand: the link, then each step as <id>: <title> (<contributors>). See Explain.
  • In the README of the app, the section "Finish the setup" (AppEntryRole.setupHeading), which comes before every other section. It lists the titles, numbered, with the link to their guide. Only the template of the app entry role writes it: AppEntryRole.readmeSections refuses that heading from anyone else.
  • In AGENTS.md, a second note of the app entry role, which tells coding agents that only the developer of the app does these steps.

An app without steps has none of these.

The id and the title​

ManualStepId(module, name) names a step of a module. The name is lower snake_case, such as apns_key, and no other step of the owner has it. ManualStepId.wire is the id as the link carries it, <owner>.<name>, such as firebase_messaging.apns_key. It names the step for good: a link that a run once printed still leads to the guide of the step, so never rename a step.

The title tells what the developer does, and the guide tells how. It is one line of at most 80 characters (ManualStep.maxTitleLength) with no spaces around it, starts with an upper-case letter or with iOS, and has neither a final period nor a backslash.

When only an app with a role needs the step, and the module only uses that role, the step lists the role in its when, as other contributions do.

The pipeline reports, as a problem of the contributor:

  • a name that is not lower snake_case;
  • an id of another module (a variant counts as its module), and an id of a module from the template of a role;
  • an id of a role that the contributor does not provide, require or use, and that is not open to all modules;
  • a title against its rules;
  • a second title under one id, at the contributor that comes later in the order of the steps.

So in lenient mode, smf create leaves out the module at fault.

A step of a role​

Contributors that do not know each other may need the same step. A role publishes such a step as a constant of its class, with ManualStepId.role(role, name). Each contributor lists it, and the app has it once. A step with an id of a role applies only in an app with that role. The registry refuses a role with the id of a module, so steps of two owners never share an id in the link.

The app entry role publishes one such step, AppEntryRole.iosAppIdStep (app_entry.ios_app_id), in which the developer signs the iOS app in Xcode once with a team of the paid Apple Developer Program. That registers the App ID of the app with the capabilities of its entitlements. An app needs the step when its entitlements have one that only such a team signs, one of AppEntryRole.paidTeamEntitlements: aps-environment, com.apple.developer.applesignin and com.apple.developer.associated-domains. AppEntryRole.paidTeamEntitlement(key, value, when:) gives the entry of AppEntryRole.iosEntitlements and the step together, under the same roles:

...AppEntryRole.paidTeamEntitlement(
'com.apple.developer.applesignin',
const PlistStringArray(['Default']),
),

It throws an ArgumentError for any other key: a free team signs any other entitlement, which goes into iosEntitlements alone and needs no step. The structural rule app_entry.ios_app_id_step reports an app whose ios/Runner/Runner.entitlements has a key of paidTeamEntitlements and whose steps lack this one. In SMF, apple_sign_in and the template of the push role add their entitlements this way.

Steps that depend on the data of a role​

A step that depends on what the modules give a role comes from the manualSteps hook of the template of the role or of a provider of it. firebase_auth lists the steps of Apple only in an app with a way to sign in of Apple, and the SHA of a release only in one with a way to sign in that needs the certificate of the Android app, such as Google:


List<ManualStep> manualSteps(RoleHookInput<AuthData> input) {
final methods = firebaseMethodsOf(authRole.methodsIn(input));
return [
if (methods.containsKey('apple.com')) ...const [
FirebaseAuthModule.appleProviderStep,
FirebaseAuthModule.appleRevocationStep,
],
if (methods.values
.any((needs) => needs.contains(SignInNeed.androidCertificate)))
FirebaseAuthModule.googleReleaseShaStep,
];
}
  • The hook runs once, in the validation, before the roles choose, so input.choice is null. No step depends on a choice, and --explain, which stops before the choices, has the steps too.
  • A step of the hook has no when, since the hook asks for the presence of the roles itself with input.has(role). The pipeline reports a step with when and a hook that throws.
  • The hook of a provider gives an id of its own module, or of a role that its module provides, requires or uses, or that is open to all modules. The hook of a template gives an id of its role, of a role that the role requires or uses, or of a role that is open to all modules.
  • A step that every app with the role or with the provider has is a contribution of the template or of the module instead.

The order​

The pipeline plans the steps once, in the validation. It orders them by the edges between their contributors, as it orders the steps after generation. A cycle of those edges is no problem here, since the order only tells the developer which step to do first, and the contributors on the cycle are ordered by name. Each id comes once, in the place of its first step, with every contributor that lists it. The steps of roles come first, because a role publishes a step that its contributors need before their own. The link, the README and --explain give the steps in this order.

The render hooks and the structural rules of a role that is open to all modules, which only the app entry is, get the planned steps and the link as RoleHookInput.manualSetup. Every other role gets ManualSetup.none, since the ids name modules that the role does not know of.

The pipeline knows no site. The front end names the page that guides the developer: a SetupLink, a function from the ids of the steps, in their order, to a Uri, which runSmf and CreatePipeline take as setupLink. Without one, the run prints no line about the steps, and --explain and the README list them without a link. smf create names the setup page of this site, with the ids after ?steps=, separated by commas, as in https://doc.saymyframe.com/setup?steps=app_entry.ios_app_id,firebase_messaging.apns_key. A command of your own built on runCli names the same page, which shows a step that it has no guide for by its id and points to the README of the app for its title.

This site keeps a guide for every step of the modules that smf create offers. packages/smf_flutter_cli/setup_steps.json is their catalog: each id once, with its title, its owner and the modules that bring it into an app. After a change of the steps of such a module, run dart run tool/setup_steps.dart in packages/smf_flutter_cli, which writes the file. test/setup_steps_test.dart fails until the file is up to date. A new step also needs its guide on this site, whose build compares its copy of the catalog with the guides. An id that no app has any more moves to retired in the catalog and keeps its guide, since a link that a run once printed names it.

In tests​

ContractResult.manualSteps has the planned steps of the app of a case, each a PlannedManualStep with its step and its contributors, and ContractResult.setupLink their link. The harness plans them before the roles choose, so a harness that does not render has them too, and result.app!.manualSteps has the same steps. The harness takes a setupLink, and has none by default. The tests of apple_sign_in check the step of its entitlement this way:

final planned = result.manualSteps.single;
expect(planned.step.id, AppEntryRole.iosAppIdStep.id);
expect(planned.step.title, AppEntryRole.iosAppIdStep.title);

Services​

An infrastructure module that creates services registers them as data of the DI role, and gets the services they need through the factory, as in FactoryRef('createApiClient', import: ..., deps: [ServiceRef(TypeRef('HttpClient', import: ...))]). The container calls the factory with those services, and the module never calls resolve. See registering services.

A module that implements analytics, crash reporting, events, preferences, notifications, push messages or sign-in provides the role instead; see Provide a role. A module that keeps a setting of its own in the preferences gives the preferences role a restorer; see Add a setting.