Shared preferences
Learning objectives
- You know how to store data that persists between application restarts.
There exists a wide variety of ways for storing information in a Flutter application. The options range from working with files to using external services such as Firebase. In this chapter, we'll visit a few of them, starting with the Shared preferences plugin.
Storing information
The Shared preferences plugin offers a platform independent way for storing simple data on the device. To include it to a project, it needs to be added to the pubspec.yaml
file. Here, we use the version 2.2.1
or newer.
# ...
shared_preferences: ^2.2.1
# ...
When using shared preferences, the application has an access to a SharedPreferences class, which provides an abstraction for storing and reading data. The class has an asynchronous static method getInstance
, which loads the stored data for use. A simple example of a function using the class would be as follows:
import 'package:shared_preferences/shared_preferences.dart';
// ...
Future<String> getName() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey('name')) {
await prefs.setString('name', 'Nicole-Reine Lepaute');
return 'The name was not stored!';
}
return prefs.getString('name')!;
}
When calling the above function in an application for the first time, the function returns a future with the text The name was not stored!
. On subsequent runs, the function returns the name Nicole-Reine Lepaute.
The
!
at the end ofprefs.getString('name')!
that there is a value. This is used as the functiongetString
has a return typeString?
, indicating that the returned value might not exist and be null.
Documentation
The documentation for SharedPreferences outlines the methods that SharedPreferences
offers. There are setter and getter -methods for storing booleans, integers, doubles, strings, and lists of strings, where each data type is stored with a key. The setter methods are asynchronous, while the getter methods are synchronous. In addition, there is a method containsKey
that returns a boolean value telling whether the preferences has a given key, and a method remove
, which removes the data with a given key.
Similarly, we could use shared preferences to store a count and increment it one by one.
import 'package:shared_preferences/shared_preferences.dart';
// ...
Future<int> incrementAndGetCount() {
SharedPreferences prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey('count')) {
await prefs.setInt('count', 1);
return 1;
}
int count = prefs.getInt('count')! + 1;
await prefs.setInt('count', count);
return count;
}
Example with StatelessWidget
Shared preferences can be retrieved at any point of an application. The following example outlines an application that stores the number of Like
-button clicks using shared preferences. Retrieving the number of likes and incrementing the number of likes have been implemented in separate methods getLikes
and increaseLikes
.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
main() {
runApp(LikeApp());
}
class LikeApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: LikeWidget()));
}
}
class LikeWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Column(children: [
FutureBuilder<int>(
future: getLikes(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
return Text('Likes: ${snapshot.data}');
}
return const Text('Likes: ?');
},
),
IconButton(
icon: const Icon(Icons.thumb_up),
onPressed: () => incrementLikes(),
),
]);
}
}
Future<int> getLikes() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('likes')) {
return prefs.getInt('likes')!;
}
return 0;
}
incrementLikes() async {
final likes = await getLikes();
final prefs = await SharedPreferences.getInstance();
prefs.setInt('likes', likes + 1);
}
When you run the application, you see a number of likes. Unsurprisingly, as the state of the application is not managed, the number of likes does not change when you press the button.
Shared preferences and FutureProvider
A somewhat better option would be to use a FutureProvider for handling the like count. In this case, we would use a ConsumerWidget
, and call the incrementLikes
function just before refreshing the FutureProvider
. This would look as follows.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final likeFutureProvider = FutureProvider<int>((ref) async {
return await getLikes();
});
main() {
runApp(ProviderScope(child: LikeApp()));
}
class LikeApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: LikeWidget()));
}
}
class LikeWidget extends ConsumerWidget {
_incrementAndRefresh(WidgetRef ref) async {
await incrementLikes();
ref.refresh(likeFutureProvider);
}
Widget build(BuildContext context, WidgetRef ref) {
final likeFuture = ref.watch(likeFutureProvider);
return Column(children: [
likeFuture.when(
loading: () => const Text('Loading likes'),
error: (error, stackTrace) => const Text('Error loading likes'),
data: (likeCount) => Text('Likes: $likeCount')),
IconButton(
icon: const Icon(Icons.thumb_up),
onPressed: () => _incrementAndRefresh(ref),
)
]);
}
}
Future<int> getLikes() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('likes')) {
return prefs.getInt('likes')!;
}
return 0;
}
incrementLikes() async {
final likes = await getLikes();
final prefs = await SharedPreferences.getInstance();
prefs.setInt('likes', likes + 1);
}
The approach is still a bit cumbersome, as we rely on creating a new instance of SharedPreferences
on each function call.
Shared preferences in a Provider
Riverpod provides also the possibility to use a dependency that requires asynchronous initialization. This is discussed in the Riverpod documentation on Scopes, where an example on Initialization of Synchronous Provider for Async APIs outlines a very similar case to ours.
Mimicking the example, we can create a sharedPreferencesProvider
with dummy content, which is then overridden during application startup.
// ...
final sharedPreferencesProvider =
Provider<SharedPreferences>((ref) => throw UnimplementedError());
main() async {
final prefs = await SharedPreferences.getInstance();
runApp(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: LikeApp(),
));
}
// ...
Relying on the sharedPreferencesProvider
, we can implement a StateProvider
that handles the likes. A key part in the implementation is the ref.listenSelf
part, which allows updating the value in SharedPreferences
.
// ...
final likeProvider = StateProvider<int>((ref) {
final preferences = ref.watch(sharedPreferencesProvider);
final currentValue = preferences.getInt('likes') ?? 0;
ref.listenSelf((prev, curr) {
preferences.setInt('likes', curr);
});
return currentValue;
});
// ...
With this, we could discard the getLikes
and incrementLikes
altogether. The resulting application would look as follows.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final likeProvider = StateProvider<int>((ref) {
final preferences = ref.watch(sharedPreferencesProvider);
final currentValue = preferences.getInt('likes') ?? 0;
ref.listenSelf((prev, curr) {
preferences.setInt('likes', curr);
});
return currentValue;
});
final sharedPreferencesProvider =
Provider<SharedPreferences>((ref) => throw UnimplementedError());
main() async {
final prefs = await SharedPreferences.getInstance();
runApp(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: LikeApp(),
));
}
class LikeApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: LikeWidget()));
}
}
class LikeWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final likes = ref.watch(likeProvider);
return Column(children: [
Text('Likes: $likes'),
IconButton(
icon: const Icon(Icons.thumb_up),
onPressed: () => ref.read(likeProvider.notifier).state = likes + 1,
)
]);
}
}
Waiting for it
The author of Riverpod has acknowledged that there should be an easy integration with offline storage solutions and is working on it.
Working with more complex data
While SharedPreferences
has methods for storing booleans, integers, strings, and lists of strings, it is also possible to store more complex data. One approach is to serialize the stored data as a string, e.g. in JSON format, and then modify the stored data whenever something changes. Let's look into storing tasks with SharedPreferences. We strongly rely on our prior task manager with Riverpod example.
First, to load and store objects using the JSON format, a few convenience methods need to be added to the Task
class. The method toJson
describes how the data is stored in a JSON format, while the method fromJson
describes how a task is created from a JSON object. A Task
class with a name and a priority would look as follows.
class Task {
final String name;
final bool priority;
Task({required this.name, required this.priority});
Task.fromJson(Map<String, dynamic> json)
: name = json['name'],
priority = json['priority'];
Map<String, dynamic> toJson() => {
'name': name,
'priority': priority,
};
}
In the task manager with Riverpod example, we relied on StateNotifier and StateNotifierProvider to work with a more complex state. Building on top of the prior example, we can include the sharedPreferencesProvider
to the mix, which we can use to retrieve and store the JSON formatted list of tasks.
class TaskNotifier extends StateNotifier<List<Task>> {
final SharedPreferences prefs;
TaskNotifier(this.prefs) : super([]);
_initialize() {
if (!prefs.containsKey("tasks")) {
return;
}
Iterable tasks = json.decode(prefs.getString("tasks")!);
state = List<Task>.from(tasks.map((t) => Task.fromJson(t)));
}
addTask(Task task) {
state = [task, ...state];
prefs.setString("tasks", json.encode(state));
}
}
final taskProvider = StateNotifierProvider<TaskNotifier, List<Task>>((ref) {
final tn = TaskNotifier(ref.watch(sharedPreferencesProvider));
tn._initialize();
return tn;
});
final sharedPreferencesProvider =
Provider<SharedPreferences>((ref) => throw UnimplementedError());
The initialization of the taskProvider
would create an instance of a TaskNotifier
, passing the sharedPreferences
from the sharedPreferencesProvider
to it. After creating the TaskNotifier
, its state would be initialized, calling the _initialize
method that reads the JSON-formatted string of tasks from the shared preferences object, and creates a list of task objects out of them. This would lead to a situation, where the application shows a list of tasks loaded from the shared preferences upon program startup.
Other than overriding the sharedPreferencesProvider
with an actual instance of SharedPreferences
, as outlined below, the rest of the application would not need to be changed.
main() async {
final prefs = await SharedPreferences.getInstance();
runApp(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: TaskApp(),
));
}