Skip to main content

Preferences and settings

With a module that provides the preferences role, such as shared_preferences, a generated app remembers its settings between its launches. An app with material_theme or gen_l10n always has such a module, since it remembers the theme mode and the language. With settings, the app has a screen that shows its settings. This page shows how the code you write uses both, and How settings work explains the roles behind them.

warning

The preferences are for settings, such as the theme mode or whether the user has dismissed a hint, and not for secrets. Nothing in them is encrypted, and a backup of the device carries them. Never store a token, a password, an API key or an encryption key there. A secret belongs in the secure storage of the platform, the Keychain on iOS and the Keystore on Android.

The preferences of the app​

The preferences role generates lib/core/preferences/app_preferences.dart, whichever module provides it:

AppPreferencesThe interface of the preferences: getString, getBool, getInt, getDouble and getStringList, a set… for each of them, and remove.
initPreferences()Opens the preferences and gives them to the restorers, described below. bootstrap() awaits it in its platform phase, before the services of the app and before the first frame.
createAppPreferences()Returns the preferences that initPreferences() opened.

A read is synchronous, from memory, and never throws. It returns null when its key has no value of the type it asks for, so getString of a key with a bool is null. Whether a number saved as an int is read as a double, or the other way round, depends on the module. With shared_preferences it is not, so read a setting as the type you saved it as.

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. remove puts a setting back to its default, and the reads of its key return null again.

If the preferences cannot be opened, initPreferences() fails, and bootstrap() with it.

Remember a setting​

Code that remembers a setting has three parts: the state that the widgets listen to, a function that changes the state and saves it, and a restorer, which reads what is saved when the app starts. This one remembers that the user has dismissed a hint of the home screen:

lib/features/home/hint.dart
import 'package:flutter/foundation.dart';

import '../../core/preferences/app_preferences.dart';

/// The key of the setting in the preferences of the app.
const _key = 'home.hint_dismissed';

/// Whether the user dismissed the hint of the home screen.
final ValueNotifier<bool> hintDismissed = ValueNotifier(false);

AppPreferences? _preferences;

/// Dismisses the hint, or shows it again, and saves the choice.
Future<void> chooseHint({required bool dismissed}) async {
hintDismissed.value = dismissed;
await _preferences?.setBool(_key, dismissed);
}

/// Takes the choice that is saved, or keeps the current one, and keeps
/// [preferences] for the choice to come.
void restoreHint(AppPreferences preferences) {
_preferences = preferences;
hintDismissed.value = preferences.getBool(_key) ?? hintDismissed.value;
}

A restorer is a function that takes the AppPreferences. Add it to the list of restorers in the file of the role, with the import of its file:

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

initPreferences() calls each restorer with the preferences once they are open, so the setting has its saved value before the first frame. The widgets listen to hintDismissed and call chooseHint(), and never touch the preferences themselves. The roles and the modules of the app keep their settings the same way: smf create puts their restorers into the same list, such as restoreAppThemeMode of the theme and restoreOnboarding of the onboarding.

A restorer is synchronous, keeps the current value when nothing is saved under its key, as ?? hintDismissed.value does above, and may run more than once. How settings work has its rules in full.

In the code of the app, only bootstrap() calls initPreferences(), and nothing but the DI container calls createAppPreferences(). Code gets the preferences as the argument of its restorer, and the guide for coding agents of the app tells agents to do the same. A test is another matter: it calls createAppPreferences() to read and write a key, and initPreferences() to open the preferences again, as the test below does.

Keys​

Name a key <owner>.<setting>, such as home.hint_dismissed, where the owner is the feature or the concern whose code keeps the setting. The modules name theirs after their ids, so the keys of different owners do not collide. Nothing checks the form: two owners that save under one key overwrite each other's setting.

With a DI container​

With a module of dependency injection, such as get_it, the preferences role also registers the preferences in the container as a lazy singleton. A service takes them as a dependency of its factory, and the composition file of a feature takes them with resolve<AppPreferences>() and hands them to the state of the feature, such as a Cubit. See Services and state.

The settings screen​

With settings, the app has SettingsScreen at /settings. Below its title, it shows the settings that the roles and the modules of the app have, such as the theme mode and the language, in one group on a card. With a layout, such as bottom_tabs, the screen is a destination of the main navigation. Without one, open it from your own code:

context.nav.settings.settings().push<void>()

An entry for a setting of your own is a widget that reads and changes the setting through its state, as the other entries do:

lib/features/home/hint_setting.dart
import 'package:flutter/material.dart';

import 'hint.dart';

/// The row of the settings screen that hides the hint of the home screen,
/// or shows it again.
class HintSetting extends StatelessWidget {
/// Creates the row.
const HintSetting({super.key});


Widget build(BuildContext context) => ValueListenableBuilder<bool>(
valueListenable: hintDismissed,
builder: (context, dismissed, _) => SwitchListTile(
secondary: const Icon(Icons.lightbulb_outline),
title: const Text('Hide the hint of the home screen'),
value: dismissed,
onChanged: (value) => chooseHint(dismissed: value),
),
);
}

The screen is in lib/features/settings/settings_screen.dart. Import the file of the entry there with a prefix that no other import of the screen has, as the screen imports the entries of the modules, since two entries may have the same class name. The screen creates its entries as constants, so the entry has a const constructor.

In an app without settings​

While no module of the app has a setting, the screen shows a note for you in place of the group; see settings. The first entry takes the place of the note. Replace the SliverFillRemaining with the note by the entry on a Card:

lib/features/settings/settings_screen.dart
import '../home/hint_setting.dart' as hint;

// ...
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
sliver: SliverToBoxAdapter(child: title),
),
const SliverPadding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 32),
sliver: SliverToBoxAdapter(
child: Card(
clipBehavior: Clip.antiAlias,
child: hint.HintSetting(),
),
),
),
],
),

Then delete the class _NoSettings and the constant _file from the file, which nothing uses after that.

In an app with settings​

In an app with a module that brings a setting, such as material_theme, the screen has the group. Add the entry to its children, here after the entry of the theme mode:

lib/features/settings/settings_screen.dart
import '../home/hint_setting.dart' as hint;

// ...
const _Group(
children: [entry0.ThemeModeSetting(), hint.HintSetting()],
),

The group draws a line between its entries.

In tests​

A test shows that a setting is saved by reading its key after a choice. It shows that a setting is remembered by writing the key through AppPreferences, running initPreferences() again, as the next launch does, and reading the state:

test/features/home/hint_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/bootstrap.dart';
import 'package:my_app/core/preferences/app_preferences.dart';
import 'package:my_app/features/home/hint.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';

void main() {
test('the choice is saved, and the next start reads it', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
await bootstrap();
expect(hintDismissed.value, isFalse);

// Saved: the key has the choice once the write completes.
await chooseHint(dismissed: true);
expect(createAppPreferences().getBool('home.hint_dismissed'), isTrue);

// Remembered: write the key, open the preferences again as the next
// launch does, and read the state.
await createAppPreferences().setBool('home.hint_dismissed', false);
await initPreferences();
expect(hintDismissed.value, isFalse);
});
}

Running initPreferences() again right after a choice proves nothing, since memory has the value already. Nor does it stand for the next launch once a key was removed: the restorer finds nothing saved and keeps the value that the state has, where a new launch starts with the default.

The first statement of the test puts the preferences of shared_preferences into memory. Without it, opening the preferences throws. Its two names come from the package shared_preferences_platform_interface, which the test needs as a dev dependency; see shared_preferences.

A DI container that created the preferences before keeps the object of the earlier open. So a test of code that takes them from the container also runs resetDependencies() and registerDependencies() once initPreferences() ran again.