Flutter Apps

13.1 Data Persistence

Module Still Under Development

# Shared Preferences

SharedPreferences in Flutter are a lot like SessionStorage in the browser. You create a String key for each value you want to save and then save a value associated with that key.

The primary differences are:

  • that you don't have to worry about other apps or webpages interfering with your key names because all the values are connected to your app and the code that you are writing.
  • When you save or retrieve the value, you need to know what the datatype is for the information that you are saving.
  • The read write operations for SharedPreferences are asynchronous so we need to use Future and possibly async and await.
  • While the data might exist the next time you launch the app, there is no guarantee.

To use SharedPreferences we need to import the dart package shared_preferences from pub.dev.

Add shared_preferences: ^2.2.2 to your pubspec.yaml dependencies list and run flutter pub get (if VSCode does not run it for you).

Then you can import it at the top of any file that needs it.

import 'package:shared_preferences/shared_preferences.dart';

//inside an async function we will access the SharedPreferences storage for our app
final prefs = await SharedPreferences.getInstance();
1
2
3
4

Here is the shared_preferences reference.

Once you have a reference to the SharedPreferences storage instance for your app then you can start saving data. The data that you save must be one of the following types: boolean, string, integer, double, or List of Strings.

//To SAVE values use these methods
// Save an integer value to 'counter' key.
await prefs.setInt('counter', 10);
// Save an boolean value to 'repeat' key.
await prefs.setBool('repeat', true);
// Save an double value to 'decimal' key.
await prefs.setDouble('decimal', 1.5);
// Save an String value to 'action' key.
await prefs.setString('action', 'Start');
// Save an list of strings to 'items' key.
await prefs.setStringList('items', <String>['Earth', 'Moon', 'Sun']);
1
2
3
4
5
6
7
8
9
10
11

To read the values from your SharedPreferences instance

// Try reading data from the 'counter' key. If it doesn't exist, returns null.
final int? counter = prefs.getInt('counter');
// Try reading data from the 'repeat' key. If it doesn't exist, returns null.
final bool? repeat = prefs.getBool('repeat');
// Try reading data from the 'decimal' key. If it doesn't exist, returns null.
final double? decimal = prefs.getDouble('decimal');
// Try reading data from the 'action' key. If it doesn't exist, returns null.
final String? action = prefs.getString('action');
// Try reading data from the 'items' key. If it doesn't exist, returns null.
final List<String>? items = prefs.getStringList('items');
1
2
3
4
5
6
7
8
9
10

To remove an entry from your SharedPreferences instance, use the remove method.

// Remove data for the 'counter' key.
final success = await prefs.remove('counter');
1
2

It is worth noting that the write and delete methods are asynchronous and require the await keyword, but the read methods do not.

But, what about my big chunk of JSON, you ask? What if I have an array of numbers plus a whole host of other properties that I need to save?

The answer comes from the fact that JSON is already a String. If the data came from an API as a JSON String then you can simply use the await prefs.setString() method.

If you have data that you have gathered from other locations, like a form, and you want to save that information in SharedPreferences, then you can put all your information into a Map and convert that Map into a JSON String using the dart:convert package.

import 'package:shared_preferences/shared_preferences.dart';
import 'data:convert';

String convertToJsonAndSave (String key, String name, int id, String email) async{
  //pass in the values you want to save as a JSON string
  //plus a key to use for your saved data in SharedPreferences
  //access the Shared Preferences instance for your app
  final prefs = await SharedPreferences.getInstance();

  //build the map to hold the parameters passed to the function
  Map<String, dynamic> data = {
    'id': id,
    'name': name,
    'email': email,
  };

  //convert the Map to a JSON string
  String jsonStr = json.encode(data);
  //write the String to SharedPreferences
  await prefs.setString(key, jsonStr);
  //return the saved string in case it is needed elsewhere
  return jsonStr;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# Path Provider

Getting the location of files that are saved on the device requires knowing about two folders - the temp folder and the app document directory. The pub.dev package path_provider can get us these two locations. We will also need the dart:io package to be imported.

import 'dart:io';
import 'package:path_provider/path_provider.dart';

void _getFolders() {
  Directory tempDir = await getTemporaryDirectory();
  Directory appDocDir = await getApplicationDocumentDirectory();
  String tempPath = tempDir.path;
  String appDocPath = appDocDir.path;
}
1
2
3
4
5
6
7
8
9

A useful application of the path and path_provider packages is the ability to take pictures and save them in an app folder. The Flutter Cookbook section has a recipe on how to do this.

Cookbook Recipe for Camera.

# Reading and Writing JSON

One of the alternatives to temporary storage via Shared Preferences is to read and write data to local JSON files. This gives you a permanent way to store information that will definitely be there the next time the app starts.

If you import the dart:io package, you will be able to work with File and Directory objects.

Here is an example of a helper class for files, similar to how we create a helper class for Http functionality.

import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:convert';
import 'dart:io';
import 'dart:async';

class FileHelper {

  //a class for reading the contents from a data.json file
  Future<Map<String, dynamic>> readContents(String filename) async {
    Directory dataDir = await getApplicationDocumentsDirectory();
    //find the app Documents directory
    //open the file filename inside Documents/data
    String path = '${dataDir.path}/data/${filename}';

    File file = File(path); //create a file object
    bool doesExist = await file.exists(); //check if the file actually exists
    if (doesExist) {
      //read the contents of the file as a String
      return file.readAsString().then((String contents) {
        //convert the string from the file into a Map object
        //this could be a List...depends on the file.
        Map<String, dynamic> data = jsonDecode(contents);
        //now the json and data props have values
        if (kDebugMode) {
          print(contents);
          print('file existed and read');
        }
        //this example function returns both the String and Map versions of the file contents
        return {'json': contents, 'data': data};
      }).catchError((err) {
        if (kDebugMode) {
          print('failed to read file ${err}');
        }
        return {'json': '', 'data': {}};
        // throw Exception('Unable to read file.');
      });
    } else {
      //create the file if it doesn't exist.
      //the recursive property means that missing folders in the path
      //will also be created.
      file.create(recursive: true);
      print('created file');
      return {'json': '', 'data': {}};
    }
  }

  Future<void> writeData(String filename, String jsonString) async {
    //get the Documents directory inside the App
    Directory dataDir = await getApplicationDocumentsDirectory();
    final file = File('${dataDir.path}/data/${filename}');
    return file.exists().then((bool exists) async {
      if (exists) {
        try {
          // Write JSON string to the local file
          await file.writeAsString(jsonString);
          if (kDebugMode) {
            print('Map data written to file: ${file.path}');
          }
        } catch (err) {
          if (kDebugMode) {
            print('Error writing to file: $err');
          }
        }
      } else {
        //no file
        if (kDebugMode) {
          print('No such file: ${file.path}');
        }
      }
    });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

Good to note that Documents directory is inside the app once the app is installed. It is NOT a directory that exists inside your Flutter project structure. If you want to put files inside there then you need to do that with code.

Once you have the Map object from the JSON file you can access any of the values via their key.

class _HomeBodyState extends State<HomeBody> {
  Map<String, dynamic> data = {};
  String json = '';

  void getFileContents() async {
    FileHelper fileHelper = FileHelper();
    fileHelper.readContents('data.json').then((Map<String, dynamic> details) {
      setState(() {
        data = details['data'];
        json = details['json'];
      });
    }).onError((error, stackTrace) {
      setState(() {
        data = {};
        json = 'No file contents.';
      });
    });
  }

  
  void initState() {
    getFileContents();
    super.initState();
  }

//Now you have access to the values via their key like
// data['somekey']
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

Repo example of reading and writing JSON files

The File class also includes methods like copy for making copies of files, rename for renaming a file, readAsBytes for reading the data inside a binary file such as an image, writeAsString and writeAsBytes.

# Providers and Consumers

Another way that you can save data globally and make it accessible to the rest of your app is by creating a Provider. This is similar to Context objects and their providers in React.

To start with you need to add the provider package as a dependency in your pubspec.

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  provider: ^6.1.1
1
2
3
4
5

The provider package reference

There are several different Providers that you can use at the top of your Widget tree.

  • MultiProvider: when you want to load several providers at the top level.
  • Provider: when you want to save static data at the top level.
  • ChangeNotifierProvider: when you want global data that you want to read and write.

The most common, and the one we will discuss here, is the ChangeNotifierProvider.

Start by adding the provider as a wrapper around your App inside the runApp() method inside your main() function. It has two properties - create which builds the provider using a data model class, and child which is your app.

//main.dart
void main(){
  runApp(
    ChangeNotifierProvider(
      create: (context) => MyDataModel(),
      child: MyApp(),
    )
  );
}
1
2
3
4
5
6
7
8
9

The data model is a simple class that contains the member fields which hold the data that you want to access throughout the app. The member fields can be any data type that you want. Import this data model class into main.dart where it is referenced in the create property value.

Typically you will have private variable that hold the values and then create a getter and a setter method for each.

//my_data_model.dart

class MyDataModel extends ChangeNotifier {
  //default values
  int _num = 42;
  String _name = 'Douglas';
  //getter and setter for _num
  int get num => _num;
  set num(int value){
    if(value > 0 && value < 100){
      _num = value;
    }
  }
  //getter and setter for _name
  String get name => _name.toUpperCase();
  set name(String value){
    if(value.length > 0){
      _name = name;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

The data model file also needs to be imported on any file where you want to access the data, along with provider package import.

On each page, or in each widget that needs to access the data from the provider we need to add a Consumer() widget as the first widget inside the build method. The Consumer widget needs to be typed as the data model type - Consumer<MyDataModel>(). The Consumer object has one property - builder which accepts a function that will create the Widget tree inside the consumer.


Widget build(BuildContext context){
  return Consumer<MyDataModel>(
    builder: (context, value, child) => Center( )
  );
}
1
2
3
4
5
6

Inside the child of the Consumer, you can use the value variable from the function parameters to access the static value of any of the variables inside the data model. These are a snapshot of the values at the point where the build function ran.

Column(
  children: [
    Text(value.num), //retrieve the value from the `get num` function in the data model
    Text(value.name), //retrieve the value from the `get name` function in the data model
  ],
)
1
2
3
4
5
6

If you want to access the values and also be able to update the values, then we use a different approach. We use the context parameter from the function, and call the read method to retrieve the typed instance of the data model class. Once you have the instance of the data model then you can access any of the member fields via their getters and setters.

TextButton(
  child: Text('click me'),
  onPressed: () {
    final model = context.read<MyDataModel>();
    model.num = 77; // calling the set num method
    model.name = 'Arthur Dent'; //calling the set name method
  }
)
1
2
3
4
5
6
7
8

Repo example of using a Provider

# ValueNotifier

The ValueNotifier class is a subclass of a ChangeNotifier, which we used in our Provider. The difference with the ValueNotifier is that it holds a single value.

It is able to hold this single value and then notify all of its listeners if the value property changes. The listeners are parts of the interface that are using this value.

To access or change (get or set) the value property inside the ValueNotifier.

final ValueNotifier<int> age = ValueNotifier(42);
//this variable can be used across multiple widgets


Widget build(BuildContext context) {
  return Column(
    children: <Widget>[
      Text('${age.value}'),
      TextButton(
        child: Text('click to change'),
        onPressed: () {
          //set a new value
          age.value = Random().nextInt(100);
          //any widget that is accessing age.value
          //will be informed about the new value.
        }
      )
    ],
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

Use state variables when the variable's scope and changes are limited to a specific StatefulWidget and its associated State.

Use ValueNotifier when you need to manage a value that's observable across multiple widgets or when you want to notify multiple parts of your app about changes in the value. It is better for more complex scenarios where multiple widgets need to respond to changes in the same value.

Reference page for the ValueNotifier Class

Tutorial article about ValueNotifier

# Data Storage Options

There are a few other options that you can use in a Flutter app for permanent data storage. Databases that you can use in your app include:

You can use any of these as a permanent LOCAL data storage solution. This is for times when you have a lot of data to manage but are not working with an external API that maintains your data.

# HiveDB

The HiveDB package works like LocalStorage in the web. It is saved across different loads of the app. It works with key-value pairs. The values can be any basic datatype - int, double, bool, String, List, and Map.

# SQLite

Both iOS and Android phones have SQLite available on the device. The https://pub.dev/packages/sqlite3 package allows you to pass commands like open(), prepare(), and execute(), to the DB on the device. It works in conjunction with the https://pub.dev/packages/sqlite3_flutter_libs package that is needed to talk with the native libraries which are needed to run the SQLite DB.

# Isar DB

Is a more full featured database solution, like SQLite, written by the authors of HiveDB. It requires a lot more work to install, configure, and run.

# What to do this week

TODO Things to do before next week

Last Updated: 12/4/2023, 11:50:38 PM