Flutter/Dart: Data persistence


There are different approaches to making the data of a flutter App persistent. Here are some examples. Here in the flutter documentation, you can read about those possibilities:

https://docs.flutter.dev/cookbook/persistence/

So, here in this post, I’m writing about the same three approaches introduced in the flutter documentation but I’m adding my own experiences while trying out those three approaches mentioned there.

Using key-value data

If the amount of data that has to be stored persistently is rather small and limited and your data can be organized in a key-value collection a reasonable approach would be using the shared_preferences plugin.

To be able to use that plugin we have to add the corresponding reference to the pubspec.yaml file.

Remark: In Flutter, the pubspec.yaml file is used to define the dependencies and packages and also the metadata of a Flutter project. You can find this file in the root directory of the project.

pubspec.yaml:

...
dependencies:
  flutter:
    sdk: flutter
  shared_preferences: "<newest version>"
...

We begin with the following simple example, which offers two text fields, allowing the user to enter a first name and a family name:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String firstName = "";
  String familyName = "";
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample Application'),
      ),
      body: Center(
        child: ListView(
          padding: const EdgeInsets.all(8),
          children: <Widget>[
            Container(
              height: 50,
              color: Colors.amber[100],
              child: Row(
                children: [
                  const Padding(
                    padding: EdgeInsets.only(right: 10.0),
                    child: Text('First Name:'),
                  ),
                  Expanded(
                    child: TextField(
                      decoration: const InputDecoration(
                        hintText: 'Enter a value',
                      ),
                      onChanged: (value) async {
                        // Assign the value entered by the user to a variable
                        firstName = value;
                      },
                    ),
                  ),
                ],
              ),
            ),
            Container(
              height: 50,
              color: Colors.amber[100],
              child: Row(
                children: [
                  const Padding(
                    padding: EdgeInsets.only(right: 10.0),
                    child: Text('Family Name:'),
                  ),
                  Expanded(
                    child: TextField(
                      decoration: const InputDecoration(
                        hintText: 'Enter a value',
                      ),
                      onChanged: (value) async {
                        // Assign the value entered by the user to a variable
                        familyName = value;
                      },
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

The application GUI looks like this:

If you run this app, enter a first name and a family name, close the app and restart the app, you will see that those values are not persistent. Now we want to expand our code to make those two values persistent.

  • We add the dependency for key-value storage
  • We add TextEditingController objects to each of the two TextFields (first name and family name). These objects allow us to access the content of each of the TextFields.
  • We store persistently any changes in the content of each of the TextFields.
  • We make sure that the stored values are, during the initialization of the class, loaded to the TextEditingController objects, that set the content of the corresponding TextFields.

main.dart:

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String firstName = "";
  String familyName = "";
  TextEditingController firstNameController = TextEditingController();
  TextEditingController familyNameController = TextEditingController();

  void _loadPersonalInfo() async {
    final prefs = await SharedPreferences.getInstance();
    final firstName = prefs.getString('firstName') ?? "";
    firstNameController.text = firstName;
    final familyName = prefs.getString('familyName') ?? "";
    familyNameController.text = familyName;
  }

  @override
  void initState() {
    super.initState();
    _loadPersonalInfo();
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample Application'),
      ),
      body: Center(
        child: ListView(
          padding: const EdgeInsets.all(8),
          children: <Widget>[
            Container(
              height: 50,
              color: Colors.amber[100],
              child: Row(
                children: [
                  const Padding(
                    padding: EdgeInsets.only(right: 10.0),
                    child: Text('First Name:'),
                  ),
                  Expanded(
                    child: TextField(
                      controller: firstNameController,
                      decoration: const InputDecoration(
                        hintText: 'Enter a value',
                      ),
                      onChanged: (value) async {
                        // Assign the value entered by the user to a variable
                        firstName = value;
                        final prefs = await SharedPreferences.getInstance();
                        await prefs.setString('firstName', firstName);
                      },
                    ),
                  ),
                ],
              ),
            ),
            Container(
              height: 50,
              color: Colors.amber[100],
              child: Row(
                children: [
                  const Padding(
                    padding: EdgeInsets.only(right: 10.0),
                    child: Text('Family Name:'),
                  ),
                  Expanded(
                    child: TextField(
                      controller: familyNameController,
                      decoration: const InputDecoration(
                        hintText: 'Enter a value',
                      ),
                      onChanged: (value) async {
                        // Assign the value entered by the user to a variable
                        familyName = value;
                        final prefs = await SharedPreferences.getInstance();
                        await prefs.setString('familyName', familyName);
                      },
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Now we can start the App, enter new values for first and family name, close the app and restart it, and we see that the values are persistent:

Using files

Another option to save data persistently is using files. This might be a good option for cases beyond simple key-value storage as described in the previous section. This option might also be a better option for cases where using a database would add too much unnecessary complexity for little benefit.

For this example, we start with a simple App that allows the user to write and edit a long text in a multiple-line TextField. Here is the code:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String textContent = "";
  TextEditingController textContentController = TextEditingController();

  void _loadPersonalInfo() async {
    textContentController.text = textContent;
  }

  @override
  void initState() {
    super.initState();
    _loadPersonalInfo();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample Application'),
      ),
      body: Center(
        child: ListView(
          padding: const EdgeInsets.all(8),
          children: <Widget>[
            Text('Text content:'),
            Container(
              height: 300,
              color: Colors.amber[100],
              child: TextField(
                maxLines: null, // allows multiple lines of text
                controller: textContentController,
                decoration: const InputDecoration(
                  hintText: 'Enter multiple lines of text',
                ),
                onChanged: (value) async {
                  // Assign the value entered by the user to a variable
                  textContent = value;
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

If you run this simple App you see a simple GUI like this, just a big multiple-line TextField allowing you to edit a long text:

But of course, the text you enter in that big text field is not persistent, because we have not done anything to make it persistent.

First, we have to add a reference for the path_rovider package that we need for file operations to the pubspec.yaml file, which is at the root of the project.

pubspec.yaml:

...
dependencies:
  flutter:
    sdk: flutter
  shared_preferences: "2.0.15"
  path_provider: ^2.0.0
...

We update the code as follows to make the content in our multiline text field persistent:

  • We add the writeContent() method to save the text box content whenever it is changed.
  • This method uses the _localFile property to access the file, to write the content into the file.
  • The _localFile property knows where the file is through the _localPath property.
  • When we start the app the readContent() method is called which uses that same _localFile property to access the previously stored file and read the content from the file. That content is then assigned to the controller which controls the content of the multiline text field.
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String textContent = "";
  TextEditingController textContentController = TextEditingController();

  void _loadContent() async {
    textContentController.text = await readContent();
  }

  @override
  void initState() {
    super.initState();
    _loadContent();
  }

  Future<String> get _localPath async {
    final directory = await getApplicationDocumentsDirectory();
    return directory.path;
  }

  Future<File> get _localFile async {
    final path = await _localPath;
    return File('$path/content.txt');
  }

  Future<File> writeContent(String content) async {
    final file = await _localFile;

    // Write the file
    return file.writeAsString(content);
  }

  Future<String> readContent() async {
    try {
      final file = await _localFile;

      // Read the file
      final contents = await file.readAsString();

      return contents;
    } catch (e) {
      // If encountering an error, return empty string
      return "";
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample Application'),
      ),
      body: Center(
        child: ListView(
          padding: const EdgeInsets.all(8),
          children: <Widget>[
            Text('Text content:'),
            Container(
              height: 300,
              color: Colors.amber[100],
              child: TextField(
                maxLines: null, // allows multiple lines of text
                controller: textContentController,
                decoration: const InputDecoration(
                  hintText: 'Enter multiple lines of text',
                ),
                onChanged: (value) async {
                  writeContent(value);
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Now you can start the app and enter some content, close the app again and restart the app and see the same content you entered before:

Using SQLite

If you have to store a large amount of data and require fast access, then using SQLite is a suitable approach.

The example presented here shows how a flutter app could store a list of behavioral events and corresponding points in time. Here are a few examples of the entries on that list:


2023-01-05 10:15 Learning about programming
2023-01-05 12:45 Eating fruits
2023-01-05 19:30 Doing exercises
2023-01-06 22:00 Learning Japanese

The app should have a button that would allow to record/count a behavioral event. The behavioral events would be written in a list. The list of events would be persistent in an SQLite DB. A delete button would allow the deletion of the entries in the DB.

So the UI of the simple app would look something like this:

To use SQLite from our flutter app we need the following dependencies:

pubspec.yaml:

...
dependencies:
  flutter:
    sdk: flutter
  sqflite:
  path:
...

Import the necessary packages in the main.dart file:

import 'package:sqflite/sqflite.dart';

Create the class that represents the data model (we put it inside main.dart):

class BehavioralEvent {
  final DateTime time;
  final Behavior behavior;

  const BehavioralEvent({
    required this.time,
    required this.behavior,
  });
}

Here is the code of the simple app (the important parts are colored and explained further below):

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:intl/intl.dart';

// db variable
var behaviorDB;

void main() async{
  WidgetsFlutterBinding.ensureInitialized();
  // Connect to DB (if it does not exist, it will be created)
  behaviorDB = openDatabase(
    join(await getDatabasesPath(),'behavior.db'),
      // If the DB is created, the oncreate event handler is executed to create a table
      onCreate: (db, version) {
      return db.execute(
        'CREATE TABLE BehavioralEvent(id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT, behavior TEXT)',
      );
    },
    version: 1,
  );
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counterProgramming = 0;
  int _counterExercise = 0;
  int _counterEatingFruits = 0;
  int _counterLearningJapanese = 0;
  TextEditingController textContentController = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Read data from database
    _getItemsFromDb();
  }

  Future<void> insertBehavioralEvent(BehavioralEvent behavioralEvent) async {
    final db = await behaviorDB;

    await db.insert(
      'BehavioralEvent',
      behavioralEvent.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<List<BehavioralEvent>> getBehavioralEvents() async {
    final db = await behaviorDB;

    final List<Map<String, dynamic>> maps = await db.query('BehavioralEvent');

    return List.generate(maps.length, (i) {
      return BehavioralEvent(
        id: maps[i]['id'],
        timestamp: maps[i]['timestamp'],
        behavior: maps[i]['behavior'],
      );
    });
  }

  Future<void> deleteAllBehavioralEvent() async {

      // Get a reference to the database.
      final db = await behaviorDB;

      // Remove all dogs from the database.
      await db.delete('BehavioralEvent');
  }

  Future<void> _addBehavioralEvent (Behavior behavior) async {
    DateTime timeStamp = DateTime.now();
    String behaviorText = behavior.toString().split('.').last;
    var be = BehavioralEvent(
      timestamp: timeStamp.toString(),
      behavior: behaviorText,
    );
    await insertBehavioralEvent(be);

    setState(() {
      var dateFormat = DateFormat("yyyy-MM-ddTHH:mm:ss");
      var formattedTimeStamp = dateFormat.format(timeStamp);
      textContentController.text += "${formattedTimeStamp} ${behaviorText} \n";
    });
  }

  Future<void> _getItemsFromDb() async {
    List<BehavioralEvent> behavioralEvents = await getBehavioralEvents();

    behavioralEvents.forEach((behavioralEvent) {
      var timeStamp = DateTime.parse(behavioralEvent.timestamp);
      var dateFormat = DateFormat("yyyy-MM-ddTHH:mm:ss");
      var formattedTimeStamp = dateFormat.format(timeStamp);
      textContentController.text += "${formattedTimeStamp} ${behavioralEvent.behavior} \n";
    });
  }

  Future<void> _deleteItemsInDb() async {
    await deleteAllBehavioralEvent();
    textContentController.text = "";
  }

  Future<void> _incrementCounterProgramming() async {
    setState(() {
      _counterProgramming++;
      _addBehavioralEvent(Behavior.Learning_about_programming);
    });
  }

  void _incrementCounterExercise() {
    setState(() {
      _counterExercise++;
      _addBehavioralEvent(Behavior.Doing_exercises);
    });
  }

  void _incrementCounterEatingFruits() {
    setState(() {
      _counterEatingFruits++;
      _addBehavioralEvent(Behavior.Eating_fruits);
    });
  }

  void _incrementCounterLearningJapanese() {
    setState(() {
      _counterLearningJapanese++;
      _addBehavioralEvent(Behavior.Learning_Japanese);
    });
  }

  @override
  Widget build(BuildContext context) {
    return  Scaffold(
      appBar:  AppBar(
        title:  Text(widget.title),
      ),
      body: Center(
          child: ListView(
            padding: const EdgeInsets.all(8),
            children: <Widget>[
              Container(
                  height: 50,
                  color: Colors.amber[100],
                  child:Row(
                      mainAxisAlignment: MainAxisAlignment.start,
                      children: <Widget>[
                        const Text(
                          'Learning about programming',
                          textAlign: TextAlign.left,
                        ),
                        Text(
                          '$_counterProgramming',
                          style: Theme.of(context).textTheme.headline4,
                        ),
                        Spacer(),
                        FloatingActionButton(
                          onPressed: _incrementCounterProgramming,
                          tooltip: 'Decrement',
                          heroTag: 'Decrement',
                          child: const Icon(Icons.add),
                        ),
                      ]
                  )),
              Container(
                  height: 50,
                  color: Colors.amber[500],
                  child:Row(
                      mainAxisAlignment: MainAxisAlignment.start,
                      children: <Widget>[
                        const Text(
                          'Doing exercises',
                          textAlign: TextAlign.left,
                        ),
                        Text(
                          '$_counterExercise',
                          style: Theme.of(context).textTheme.headline4,
                        ),
                        Spacer(),
                        FloatingActionButton(
                          onPressed: _incrementCounterExercise,
                          tooltip: 'Decrement',
                          heroTag: 'Decrement',
                          child: const Icon(Icons.add),
                        ),
                      ]
                  )),
              Container(
                  height: 50,
                  color: Colors.amber[100],
                  child:Row(
                      mainAxisAlignment: MainAxisAlignment.start,
                      children: <Widget>[
                        const Text(
                          'Eating fruits',
                          textAlign: TextAlign.left,
                        ),
                        Text(
                          '$_counterEatingFruits',
                          style: Theme.of(context).textTheme.headline4,
                        ),
                        Spacer(),
                        FloatingActionButton(
                          onPressed: _incrementCounterEatingFruits,
                          tooltip: 'Decrement',
                          heroTag: 'Decrement',
                          child: const Icon(Icons.add),
                        ),
                      ]
                  )),
              Container(
                  height: 50,
                  color: Colors.amber[500],
                  child:Row(
                      mainAxisAlignment: MainAxisAlignment.start,
                      children: <Widget>[
                        const Text(
                          'Learning Japanese',
                          textAlign: TextAlign.left,
                        ),
                        Text(
                          '$_counterLearningJapanese',
                          style: Theme.of(context).textTheme.headline4,
                        ),
                        Spacer(),
                        FloatingActionButton(
                          onPressed: _incrementCounterLearningJapanese,
                          tooltip: 'Decrement',
                          heroTag: 'Decrement',
                          child: const Icon(Icons.add),
                        ),
                      ]
                  )),
              Container(
                  height: 50,
                  color: Colors.amber[500],
                  child:Row(
                      mainAxisAlignment: MainAxisAlignment.start,
                      children: <Widget>[
                        const Text(
                          'Delete DB entries',
                          textAlign: TextAlign.left,
                        ),
                        Spacer(),
                        FloatingActionButton(
                          onPressed: _deleteItemsInDb,
                          tooltip: 'Delete DB entries',
                          heroTag: 'Delete DB entries',
                          child: const Icon(Icons.delete),
                        ),
                      ]
                  )),
              Container(
                color: Colors.white30,
                child: SingleChildScrollView(
                  child: TextField(
                    maxLines: null, // allows multiple lines of text
                    controller: textContentController,
                  ),
                ),
              ),
            ],
          )
      ),
    );
  }
}

enum Behavior {
  Learning_about_programming,
  Eating_fruits,
  Doing_exercises,
  Learning_Japanese,
}

class BehavioralEvent {
  int id;
  final String timestamp;
  final String behavior;

  BehavioralEvent({
    this.id = 0,
    required this.timestamp,
    required this.behavior,
  });

  // Convert a BehavioralEvent object into a Map.
  Map<String, dynamic> toMap() {
    return {
      'timestamp': timestamp,
      'behavior': behavior,
    };
  }
}

If you click on a + button (e.g. the + button to add a new “Learning about programming” event) to add a new behavioral event to the list of behavioral events the corresponding event handler (e.g. _incrementCounterProgramming) is called. Here the method _addBehavioralEvent() is called and the corresponding behavioral event (e.g. Behavior.Learning_about_programming) is passed.

Inside the _addBehavioralEvent() an object of type BehavioralEvent is prepared with the passed behavior and time stamp. This object is then passed to the method insertBehavioralEvent() where the actual insert into the DB takes place. The insert uses a SQL insert command as a string which gets the command parameters from the toMap() method of the BehavioralEvent class.

This is basically the major workflow of the app. Of course, there are other parts like the initialization of the DB and the deletion of DB entries.

When the application is opened the data is loaded using the _getItemsFromDb() method.

Related topics