Skip to main content

Add a setting

The module haptics gives the code of the app a light vibration for the feedback of a tap, which the user can turn off. It is an infrastructure module with one setting, and it works with three roles that it only uses:

  • the preferences role remembers the setting between the launches of the app;
  • the settings screen role shows a row that changes it;
  • the localization role has the label of the row in the languages of the app.

The module names none of the modules that provide these roles, and it works in an app without any of them. The code below was generated into apps with and without each role and checked with flutter analyze. How settings work explains the roles.

The descriptor​

lib/smf_haptics.dart
import 'package:smf_contracts/smf_contracts.dart';
import 'package:smf_haptics/bundles/haptics_bundle.dart';
import 'package:smf_haptics/bundles/haptics_setting_bundle.dart';

/// Haptic feedback that the user can turn off: a setting that the app
/// remembers when it has preferences, with a row on the settings screen
/// when it has one.
final class HapticsModule extends SmfModule {
/// Creates the module.
const HapticsModule();

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

static const _file = ImportRef.app('core/haptics/haptics.dart');

// ... the texts and the note, below.


ModuleDescriptor get descriptor => const ModuleDescriptor(
id: id,
description: 'Haptic feedback that the user can turn off',
kind: ModuleKinds.infrastructure,
uses: {preferencesRole, settingsScreenRole, localizationRole},
);

// ... contribute(), below.
}

The module lists the three roles in uses, so each is optional. A module that cannot work without a role lists it in requires instead, and an app with the module then always has a module that provides the role.

The state​

The brick haptics holds the state of the setting and the functions that read and change it. Every app with the module gets it:

bricks/haptics/__brick__/lib/core/haptics/haptics.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
{{#has_preferences}}

import '../preferences/app_preferences.dart';
{{/has_preferences}}

/// Whether the app answers a tap with a vibration: on until the user turns
/// it off.
final ValueNotifier<bool> hapticsEnabled = ValueNotifier(true);
{{#has_preferences}}

/// The key of the choice in the preferences of the app.
const _key = 'haptics.enabled';

/// The preferences that remember the choice, once the app opened them.
AppPreferences? _preferences;
{{/has_preferences}}

/// Vibrates lightly, unless the user turned the haptic feedback off.
void tapFeedback() {
if (hapticsEnabled.value) HapticFeedback.selectionClick();
}

/// Turns the haptic feedback on or off{{#has_preferences}}, and saves the choice{{/has_preferences}}.
Future<void> chooseHaptics({required bool enabled}) async {
hapticsEnabled.value = enabled;{{#has_preferences}}
await _preferences?.setBool(_key, enabled);{{/has_preferences}}
}
{{#has_preferences}}

/// Takes the choice that is saved, or keeps the current one when nothing is
/// saved, and keeps [preferences] for the choices to come; the app calls it
/// before its first frame.
void restoreHaptics(AppPreferences preferences) {
_preferences = preferences;
hapticsEnabled.value = preferences.getBool(_key) ?? hapticsEnabled.value;
}
{{/has_preferences}}
  • The state is plain Flutter, a ValueNotifier, so the module needs no state manager and has no variants.
  • Widgets listen to hapticsEnabled and change the setting with chooseHaptics(). They never touch the preferences.
  • Everything that refers to AppPreferences, a symbol of a role that the module only uses, is inside {{#has_preferences}}. An app without preferences gets the file without it, and the setting then lasts as long as the app runs.
  • The key is <module id>.<setting>, here haptics.enabled. The ids of modules differ, so the keys of modules that keep to this form do not collide.

Remember the setting​

A module does not ask for the preferences. It gives the preferences role a restorer, a function that takes the AppPreferences, and the app calls it when it starts:

lib/smf_haptics.dart
  
List<Contribution> contribute(ModuleContext context) => [
BrickContribution(hapticsBundle),
const SocketContribution.item(
PreferencesRole.restorers,
Fragment('restoreHaptics', imports: [_file]),
),
// ... the row and the note, below.
];

The contribution needs no when, because code for a socket of a role applies only in an app with the role. In such an app, the function is in the list of restorers of the role:

lib/core/preferences/app_preferences.dart
final List<void Function(AppPreferences preferences)> _restorers = [
restoreHaptics,
];

A restorer follows the rules of the preferences role. It is synchronous, keeps the current value when nothing is saved under its key, as ?? hapticsEnabled.value does, keeps the preferences for the writes of its module, and may run more than once.

The module never calls createAppPreferences() or initPreferences(). Nothing in the preferences is encrypted, so a module keeps no token, password or key there: a secret belongs in the secure storage of the platform.

A row on the settings screen​

The row is a widget in a brick of its own, haptics_setting:

bricks/haptics_setting/__brick__/lib/core/haptics/haptics_setting.dart
import 'package:flutter/material.dart';

import 'haptics.dart';

/// The row of the settings screen that turns the haptic feedback on or off.
class HapticsSetting extends StatelessWidget {
/// Creates the row.
const HapticsSetting({super.key});


Widget build(BuildContext context) => ValueListenableBuilder<bool>(
valueListenable: hapticsEnabled,
builder: (context, enabled, _) => SwitchListTile(
secondary: const Icon(Icons.vibration),
title: Text({{{text_title}}}),
value: enabled,
onChanged: (value) => chooseHaptics(enabled: value),
),
);
}

The module contributes the brick only in an app with a settings screen, and gives the settings screen role an entry that names the widget:

lib/smf_haptics.dart
    BrickContribution(
hapticsSettingBundle,
vars: localizationRole.varsOf(id, _texts),
when: const {settingsScreenRole},
),
localizationRole.data(_texts, when: const {settingsScreenRole}),
settingsScreenRole.data(
const SettingsEntry(
widget: TypeRef(
'HapticsSetting',
import: ImportRef.app('core/haptics/haptics_setting.dart'),
),
),
),
  • The widget is a class in a file that the module generates, with a const unnamed constructor that requires no arguments. The screen knows nothing else of it, so the widget reads and changes its setting itself.
  • The screen sets the width of the row and puts no limit on its height, so the widget may be a ListTile or a column of them.
  • The brick has when: {settingsScreenRole}, so an app without a settings screen does not get the file. The entry itself needs no when, since data for a role applies only when the role is present.
  • The import of the entry spells the path of the file as the brick does, and the brick has the file at a path without variables.

The module that provides the settings screen shows the row. With settings, the screen imports the file with a prefix of its own and puts the row into its group, a card below the title of the screen:

lib/features/settings/settings_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:my_app/core/haptics/haptics_setting.dart' as entry0;

// ...
const _Group(children: [entry0.HapticsSetting()]),

Without a module with a setting, that screen has a note for the developer of the app in place of the group, so the row of haptics is what gives this app its group.

The rows of the modules come in the order the modules were chosen, and after them the rows that the templates of roles contribute.

The label and the note​

The label of the row is a text of the module, which it gives the localization role with a translation:

lib/smf_haptics.dart
  static const _texts = TextsData([
LocalizedText(
'title',
en: 'Haptic feedback',
translations: {'uk': 'Вібровідгук'},
),
]);

The template reads it as {{{text_title}}}, a variable of localizationRole.varsOf. It renders as context.l10n.hapticsTitle in an app with the localization role, and as 'Haptic feedback' in one without it. The module gives the role the texts only in an app with a settings screen, where the row is. Texts describes texts in full.

The module also tells coding agents what its code does not show, in a section of the guide of the app:

lib/smf_haptics.dart
  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.
''';

// ... and in contribute():
AppEntryRole.agentSections.entry('Haptics', AgentNote(_agentNote)),

See Notes for coding agents.

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 my_app --org com.example -m home,settings,bottom_tabs,haptics,shared_preferences --no-input

The app has two tabs, Home and Settings. The settings screen has the row Haptic feedback, and the app remembers the choice. With gen_l10n in -m too, the label of the row follows the language of the app, in English and in Ukrainian. With -m haptics alone, the app gets lib/core/haptics/haptics.dart without the code for the preferences, and no row.

What the pipeline checks​

Before it generates an app, smf create checks the rules of each role that the app has. Among other things:

  • in an app with a settings screen, the bricks of the module generate the file of the widget of each of its entries, and only in such an app when the module only uses the role;
  • in an app with the localization role, the texts of the module have valid names, an English text and translations by the code of their language, and each variable that reads a text reads one that the app has.

The contract harness runs these checks too, in an app for each subset of the roles that the module uses and the registry of the tests has a provider of, and checks the generated code:

  • the widget of every entry is a class in a file of its contributor, with a const unnamed constructor that requires no arguments;
  • the module that provides the settings screen creates the widget of every entry;
  • no file of the module calls createAppPreferences() or initPreferences();
  • in the app without a role, no file of the module refers to a symbol of that role.

Test the module​

The contract harness builds an app of the module for each subset of the roles that it uses, and checks every one:

test/haptics_module_test.dart
import 'package:smf_flutter_core/smf_flutter_core.dart';
import 'package:smf_gen_l10n/smf_gen_l10n.dart';
import 'package:smf_go_router/smf_go_router.dart';
import 'package:smf_haptics/smf_haptics.dart';
import 'package:smf_pipeline/smf_pipeline.dart';
import 'package:smf_pipeline/testing.dart';
import 'package:smf_settings/smf_settings.dart';
import 'package:smf_shared_preferences/smf_shared_preferences.dart';
import 'package:test/test.dart';

void main() {
test('every app of the module follows the rules of its roles', () async {
final harness = ContractHarness(
ModuleRegistry(const [
FlutterCoreModule(),
// The settings screen is a feature, which requires a router.
GoRouterModule(),
SettingsModule(),
SharedPreferencesModule(),
GenL10nModule(),
HapticsModule(),
]),
);
final results = await harness.checkAll();
for (final result in results) {
expect(result.errors, isEmpty, reason: '${result.contractCase}');
}
expect(await harness.uncheckedProviders(), isEmpty);
});
}

The registry has a module for each role that haptics uses, since the harness builds an app with a role only when the registry has a provider of it. It also has what those modules need themselves: SettingsModule is a feature, which requires a router, and GenL10nModule requires the preferences. Without GoRouterModule, every case with the settings screen would report that no module provides the router role.

Test a module describes the harness, and how a test reads what the module gave a role.

A setting of a role​

The template of a role contributes a setting the same way, when its role requires or uses the preferences role and the settings screen role. The theme role keeps the theme mode like this: its template gives the preferences role the restorer of the mode and the settings screen role an entry for it. The widget of such an entry is in a brick of the template, not of the module that provides the role. See Define a role.