Skip to main content

Add a step to the onboarding

A module can ask a new user for something on a page of the onboarding, such as a permission that it needs. It gives the onboarding screen role a step: an icon, a title, a text, the label of a button and the function that the button calls. The module that provides the role, such as onboarding, draws the step in the look of its own pages. The two know the role and not each other.

The notifications role adds such a step, which asks for the permission to show notifications. This page shows a step of a module photos of your own, which asks for access to the photos of the device.

The step​

The module lists the onboarding screen role in uses, since it works without an onboarding too, and gives the role an OnboardingStep:

lib/smf_photos.dart
  static const _step = ImportRef.app('core/photos/photos_step.dart');

static const _title = LocalizedText('stepTitle', en: 'Your photos');
static const _text = LocalizedText(
'stepText',
en: 'Choose photos from the device for your albums.',
);
static const _action = LocalizedText('stepAction', en: 'Allow access');
static const _later = LocalizedText('stepLater', en: 'Not now');


ModuleDescriptor get descriptor => const ModuleDescriptor(
id: id,
description: 'Photos of the device',
kind: ModuleKinds.infrastructure,
uses: {onboardingScreenRole, localizationRole},
);


List<Contribution> contribute(ModuleContext context) => [
BrickContribution(photosBundle),
onboardingScreenRole.data(
const OnboardingStep(
name: 'access',
stage: OnboardingStage.permission,
icon: Fragment(
'Icons.photo_library_outlined',
imports: [ImportRef('package:flutter/material.dart')],
),
title: _title,
text: _text,
action: _action,
later: _later,
run: FunctionRef('askPhotoAccess', import: _step),
showIf: FunctionRef('photoAccessUnasked', import: _step),
),
),
localizationRole.data(
const TextsData([_title, _text, _action, _later]),
when: {onboardingScreenRole},
),
];
FieldWhat it is
nameThe name of the step among those of the module, in lowerCamelCase. Its full name, the id of the module and the name, is photos.access, as the data of the role and the tests name it.
stageWhen the onboarding shows the step among the steps of the app. The list of stages is closed, so a module cannot add one. It has permission so far, and a step of another kind takes the stage closest to what it does.
iconAn expression of the type IconData with the imports it needs, which the onboarding shows in the cell of the page.
title, textThe title of the page and the text below it.
action, runThe label of the button and the function that it calls.
laterThe label of a second button, which goes on without calling run, or null for a step without it.
showIfThe function that says whether the onboarding shows the step, or null for a step that it always shows.

The data needs no when: data of a role applies only in an app with the role. The texts of the step are texts of the module. A module that lists the localization role gives that role each of them too, with when: {onboardingScreenRole}, and the app shows them in its language. A module without the localization role shows them in English and gives them no translations.

The functions​

run and showIf are top-level functions of a file that a brick of the module generates. They take no arguments: run returns a Future, and showIf returns a bool at once and changes nothing.

bricks/photos/__brick__/lib/core/photos/photos_step.dart
/// Whether the onboarding shows the step: while the app has not asked for
/// access to the photos.
bool photoAccessUnasked() => !photoAccess.asked;

/// Asks for access to the photos of the device: the action of the step.
Future<void> askPhotoAccess() => photoAccess.request();

The brick needs no condition either, since the code that needs the photos calls the same function. The onboarding asks showIf once when it opens, so the function reads what the app knows already, such as what the start-up restored from the preferences.

Skipping the onboarding skips its steps, and a user may tap the second button. So do not rely on the step alone: the screen that needs the access asks for it too, with the same function.

What the role generates​

Whichever module provides the onboarding, the template of the role writes the steps of the app into lib/core/onboarding/onboarding_steps.dart, as the constant list appOnboardingSteps. The labels are top-level functions that read the texts of the module in the language of the app, and the file of the functions of each module is imported with a prefix of its own, so two modules may have functions of one name:

lib/core/onboarding/onboarding_steps.dart
const List<OnboardingStep> appOnboardingSteps = [
OnboardingStep(
icon: Icons.photo_library_outlined,
title: _photosAccessTitle,
text: _photosAccessText,
action: _photosAccessAction,
run: step0.askPhotoAccess,
later: _photosAccessLater,
showIf: step0.photoAccessUnasked,
),
];

String _photosAccessTitle(BuildContext context) =>
context.l10n.photosStepTitle;

The steps come in the order of their stages. Steps of one stage come in the order of their modules, and then those of the templates of roles. The steps of one module keep its order.

What the onboarding does with a step​

The onboarding screen of every provider of the role reads appOnboardingSteps and keeps to this:

  • It asks each step for its showIf each time it shows, and leaves out a step that returns false. A step that it shows stays while the user is on it.
  • It shows each step that it kept on a page of its own, in the order of the list, with no other page between two steps: the icon, the title as a header for a screen reader, the text and the buttons. It calls the functions of the labels when it builds, so they follow the language of the app.
  • A tap on the button of the action calls run once, and the buttons of the step take no tap until the future completes. Then the onboarding goes on to the next page, also when the future fails, and the error reaches the handlers of the errors of the app. The second button goes on without calling run.
  • A swipe past a step, or Skip, calls no run.

When the app shows the onboarding is up to the provider. onboarding shows it on the first launch, with a guard of the routes; see steps of other modules. A provider names its route with an OnboardingScreenRoute, so that code which knows only the role finds the screen.

What is checked​

The module rule onboarding_screen.steps reports a step whose name is not a lowerCamelCase identifier or is the name of another step of the module, a step without an icon, texts with translations in a module that does not list the localization role, and a function in a file that the bricks of the module do not generate. In every app that the contract harness renders, onboarding_screen.step_functions checks that the functions are top-level functions of the files of the module that take no arguments, that run returns a Future and showIf a bool. onboarding_screen.steps_shown checks that a file of the provider reads appOnboardingSteps.

Test the module​

The contract harness builds an app with a role only when the registry of the tests has a provider of it. So the registry of a module with a step has a provider of the onboarding screen role, such as OnboardingModule of smf_onboarding as a dev dependency, and what that provider needs: the role requires a router, and onboarding requires the preferences. A module that lists the localization role adds a provider of it too:

test/photos_module_test.dart
final harness = ContractHarness(
ModuleRegistry(const [
FlutterCoreModule(),
// The onboarding screen role requires a router.
GoRouterModule(),
// The onboarding remembers in the preferences that it is finished.
SharedPreferencesModule(),
OnboardingModule(),
GenL10nModule(),
PhotosModule(),
]),
);
final results = await harness.checkAll();
for (final result in results) {
expect(result.errors, isEmpty, reason: '${result.contractCase}');
}
expect(await harness.uncheckedProviders(), isEmpty);

The harness then builds the apps of the module with the onboarding and without it, and with the localization and without it, and runs the rules above in each. A test of the module reads its steps from the role, not from the files of a provider:

final input = onboardingScreenRole.hookInput(result.hook!);
final steps = onboardingScreenRole.stepsIn(input);
expect(steps.map((step) => step.fullName), contains('photos.access'));

What the onboarding shows of a step, and what its buttons do, only a running app shows. SMF's CI checks it with a fixture provider and fixture steps; see Contributing.