Saving Data with Local Persistence in Flutter

In this tutorial, you’ll learn how to persist data—that is, save data on the device’s local storage directory—across app launches by using the JSON file format and saving the file to the local iOS and Android filesystem. JavaScript Object Notation (JSON) is a common open‐standard and language‐independent file data format with the benefit of being human‐readable text. Persisting data is a two‐step process; first you use the File class to save and read data, and second, you parse the data from and to a JSON format. You’ll create a class to handle saving and reading the data file that uses the File class. You’ll also create a class to parse the full list of data by using json.encode and json.decode and a class to extract each record. And you’ll create another class to handle passing an action and an individual journal entry between pages.

You’ll build a journal app that saves and reads JSON data to the local iOS NSDocumentDirectory and Android AppData filesystem. The app uses a ListView to display a list of journal entries sorted by date, and you’ll create a data entry screen to enter a date, mood, and note.

UNDERSTANDING THE JSON FORMAT

The JSON format is text‐based and is independent of programming languages, meaning any of them can use it. It’s a great way to exchange data between different programs because it is human‐readable text. JSON uses the key/value pair, and the key is enclosed in quotation marks followed by a colon and then the value like "id":"100". You use a comma (,) to separate multiple key/value pairs. Table 13.1 shows some examples.

TABLE 13.1: Key/Value Pairs

KEYCOLONVALUE
"id":"100"
"quantity":3
"in_stock":true

The types of values you can use are Object, Array, String, Boolean, and Number. Objects are declared by curly ({}) brackets, and inside you use the key/value pair and arrays. You declare arrays by using the square ([]) brackets, and inside you use the key/value or just the value. Table 13.2 shows some examples.

TABLE 13.2: Objects and Arrays

TYPESAMPLE
Object{ "id": "100", "name": "Vacation" }
Array with values only["Family", "Friends", "Fun"]
Array with key/value[ { "id": "100", "name": "Vacation" }, { "id": "102", "name": "Birthday" } ]
Object with array{ "id": "100", "name": "Vacation", "tags": ["Family", "Friends", "Fun"] }
Multiple objects with arrays { "journals":[ { "id":"4710827", "mood":"Happy" }, { "id":"427836", "mood":"Surprised" }, ], "tags":[ { "id": "100", "name": "Family" }, { "id": "102", "name": "Friends" } ] }

The following is an example of the JSON file that you’ll create for the journal application. The JSON file is used to save and read the journal data from the device local storage area, resulting in data persistence over app launches. You have the opening and closing curly brackets declaring an object. Inside the object, the journal’s key contains an array of objects separated by a comma. Each object inside the array is a journal entry with key/value pairs declaring the iddatemood, and note. The id key value is used to uniquely identify each journal entry and isn’t displayed in the UI. How the value is obtained depends on the project requirement; for example, you can use sequential numbers or calculate a unique value by using characters and numbers (universally unique identifier [UUID]).

{
 "journals":[
  {
    "id":"470827",
    "date":"2019-01-13 00:27:10.167177",
    "mood":"Happy",
    "note":"Cannot wait for family night."
  },
  {
    "id":"427836",
    "date":"2019-01-12 19:54:18.786155",
    "mood":"Happy",
    "note":"Great day watching our favorite shows."
  },
 ],
}

USING DATABASE CLASSES TO WRITE, READ, AND SERIALIZE JSON

To create reusable code to handle the database routines such as writing, reading, and serializing (encoding and decoding) data, you’ll place the logic in classes. You’ll create four classes to handle local persistence, with each class responsible for specific tasks.

  • The DatabaseFileRoutines class uses the File class to retrieve the device local document directory and save and read the data file.
  • The Database class is responsible for encoding and decoding the JSON file and mapping it to a List.
  • The Journal class maps each journal entry from and to JSON.
  • The JournalEdit class is used to pass an action (save or cancel) and a journal entry between pages.

The DatabaseFileRoutines class requires you to import the dart:io library to use the File class responsible for saving and reading files. It also requires you to import the path_provider package to retrieve the local path to the document directory. The Database class requires you to import the dart:convert library to decode and encode JSON objects.

The first task in local persistence is to retrieve the directory path where the data file is located on the device. Local data is usually stored in the application documents directory; for iOS, the folder is called NSDocumentDirectory, and for Android it’s AppData. To get access to these folders, you use the path_provider package (Flutter plugin). You’ll be calling the getApplicationDocumentsDirectory() method, which returns the directory giving you access to the path variable.

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

 return directory.path;
}

Once you retrieve the path, you append the data filename by using the File class to create a File object. You import the dart:io library to use the File class, giving you a reference to the file location.

final path = await _localPath;
Final file = File('$path/local_persistence.json');

Once you have the File object, you use the writeAsString() method to save the file by passing the data as a String argument. To read the file, you use the readAsString() method without any arguments. Note that the file variable contains the documents folder’s path and the data filename.

// Write the file
file.writeAsString('$json');
// Read the file
String contents = await file.readAsString();

As you learned in the “Understanding the JSON Format” section, you use a JSON file to save and read data from the device local storage. The JSON file data is stored as plain text (strings). To save data to the JSON file, you use serialization to convert an object to a string. To read data from the JSON file, you use deserialization to convert a string to an object. You use the json.encode() method to serialize and the json.decode() method to deserialize the data. Note that both the json.encode() and json.decode() methods are part of the JsonCodec class from the dart:convert library.

To serialize and deserialize JSON files, you import the dart:convert library. After calling the readAsString() method to read data from the stored file, you need to parse the string and return the JSON object by using the json.decode() or jsonDecode() function. Note that the jsonDecode() function is shorthand for json.decode().

// String to JSON object
final dataFromJson = json.decode(str);
// Or
final dataFromJson = jsonDecode(str);

To convert values to a JSON string, you use the json.encode() or jsonEncode() function. Note that jsonEncode() is shorthand for json.encode(). It’s a personal preference deciding which approach to use; in the exercises, you’ll be using json.decode() and json.encode().

// Values to JSON string
json.encode(dataToJson);
// Or
jsonEncode(dataToJson);

FORMATTING DATES

To format dates, you use the intl package (Flutter plugin) providing internationalization and localization. The full list of available date formats is available on the intl package page site at https://pub.dev/packages/intl. For our purposes, you’ll use the DateFormat class to help you N format and parse dates. You’ll use the DateFormat named constructors to format the date according to the specification. To format a date like Jan 13, 2019, you use the DateFormat.yMMD() constructor, and then you pass the date to the format argument, which expects a DateTime. If you pass the date as a String, you use DateTime.parse() to convert it to a DateTime format.

// Formatting date examples
print(DateFormat.d().format(DateTime.parse('2019-01-13')));
print(DateFormat.E().format(DateTime.parse('2019-01-13')));
print(DateFormat.y().format(DateTime.parse('2019-01-13')));
print(DateFormat.yMEd().format(DateTime.parse('2019-01-13')));
print(DateFormat.yMMMEd().format(DateTime.parse('2019-01-13')));
print(DateFormat.yMMMMEEEEd().format(DateTime.parse('2019-01-13')));

I/flutter (19337): 13
I/flutter (19337): Sun
I/flutter (19337): 2019
I/flutter (19337): Sun, 1/13/2019
I/flutter (19337): Sun, Jan 13, 2019
I/flutter (19337): Sunday, January 13, 2019

To build additional custom date formatting, you can chain and use the add_*() methods (substitute the * character with the format characters needed) to append and compound multiple formats. The following sample code shows how to customize the date format:

// Formatting date examples with the add_* methods
print(DateFormat.yMEd().add_Hm().format(DateTime.parse('2019-01-13 10:30:15')));
print(DateFormat.yMd().add_EEEE().add_Hms().format(DateTime.parse('2019-01-13 10:30:15')));

I/flutter (19337): Sun, 1/13/2019 10:30
I/flutter (19337): 1/13/2019 Sunday 10:30:15

SORTING A LIST OF DATES

You learned how to format dates easily, but how would you sort dates? The journal app that you’ll create requires you to show a list of entries, and it would be great to be able to display the list sorted by date. In particular, you want to sort dates to show the newest first and oldest last, which is known as DESC (descending) order. Our journal entries are displayed from a List, and to sort them, you call the List().sort() method.

The List is sorted by the order specified by the function, and the function acts as a Comparator, comparing two values and assessing whether they are the same or whether one is larger than the other—such as the dates 2019‐01‐20 and 2019‐01‐22 in Table 13.3. The Comparator function returns an integer as negative, zero, or positive. If the comparison—for example, 2019‐01‐20 > 2019‐01‐22—is true, it returns 1, and if it’s false, it returns ‐1. Otherwise (when the values are equal), it returns 0.

TABLE 13.3: Sorting Dates

COMPARETRUESAMEFALSE
date2.compareTo(date1)10‐1
2019‐01‐20 > 2019‐01‐22‐1
2019‐01‐20 < 2019‐01‐221
2019‐01‐22 = 2019‐01‐220

Let’s take a look by running the following sort with actual DateTime values sorted by DESC date. Note to sort by DESC, you start with the second date, comparing it to the first date like this: comp2.date.compareTo(comp1.date).

_database.journal.sort((comp1, comp2) => comp2.date.compareTo(comp1.date));

// Results from print() to the log
I/flutter (10272): -1 - 2019-01-20 15:47:46.696727 - 2019-01-22 17:02:47.678590
I/flutter (10272): -1 - 2019-01-19 15:58:23.013360 - 2019-01-20 15:47:46.696727
I/flutter (10272): -1 - 2019-01-19 13:04:32.812748 - 2019-01-19 15:58:23.013360
I/flutter (10272): 1 - 2019-01-22 17:21:12.752577 - 2018-01-01 16:43:05.598094
I/flutter (10272): 1 - 2019-01-22 17:21:12.752577 - 2018-12-25 02:40:55.533173
I/flutter (10272): 1 - 2019-01-22 17:21:12.752577 - 2019-01-16 02:40:13.961852

I wanted to show you the longer way to the previous code’s sort() to show how the compare result is obtained.

_database.journal.sort((comp1, comp2) {
 int result = comp2.date.compareTo(comp1.date);
 print('$result - ${comp2.date} - ${comp1.date}');
 return result;
});

If you would like to sort the dates by ASC (ascending) order, you can switch the compare statement to start with comp1.date to comp2.date.

_database.journal.sort((comp1, comp2) => comp1.date.compareTo(comp2.date));

RETRIEVING DATA WITH THE FUTUREBUILDER

In mobile applications, it is important not to block the UI while retrieving or processing data. A FutureBuilder widget works with a Future to retrieve the latest data without blocking the UI. The three main properties that you set are initialDatafuture, and builder.

  • initialData: Initial data to show before the snapshot is retrieved.Sample code:[]
  • future: Calls a Future asynchronous method to retrieve data.Sample code:_loadJournals()
  • builder: The builder property provides the BuildContext and AsyncSnapshot (data retrieved and connection state). The AsyncSnapshot returns a snapshot of the data, and you can also check for the ConnectionState to get a status on the data retrieval process.Sample code:(BuildContext context, AsyncSnapshot snapshot)
  • AsyncSnapshot: Provides the most recent data and connection status. Note that the data represented is immutable and read‐only. To check whether data is returned, you use the snapshot.hasData. To check the connection state, you use the snapshot.connectionState to see whether the state is activewaitingdone, or none. You can also check for errors by using the snapshot.hasError property.Sample code:builder: (BuildContext context, AsyncSnapshot snapshot) {return !snapshot.hasData? CircularProgressIndicator(): _buildListView(snapshot);},

The following is some FutureBuilder() sample code:

FutureBuilder(
 initialData: [],
 future: _loadJournals(),
 builder: (BuildContext context, AsyncSnapshot snapshot) {
  return !snapshot.hasData
    ? Center(child: CircularProgressIndicator())
    : _buildListViewSeparated(snapshot);
 },
),

BUILDING THE JOURNAL APP

You’ll be building a journal app with the requirement of persisting data across app starts. The data is stored as JSON objects with the requirements of tracking each journal entry’s iddatemood, and note (Figure 13.1). As you learned in the “Understanding the JSON Format” section, the id key value is unique, and it’s used to identify each journal entry. The id key is used behind the scenes to select the journal entry and is not displayed in the UI. The root object is a key/value pair with the key name of 'journal' and the value as an array of objects containing each journal entry.

Screenshot of Journal app.
FIGURE 13.1: Journal app

The app has two separate pages; the main presentation page uses a ListView sorted by DESC date, meaning last entered record first. You utilize the ListTile widget to format how the List of records is displayed. The second page is the journal entry details where you use a date picker to select a date from a calendar and TextField widgets for entering the mood and note data. You’ll create a database Dart file with classes to handle the file routines, database JSON parsing, a Journal class to handle individual records, and a JournalEdit class to pass data and actions between pages.

OVERVIEW OF JOURNAL APP

You’ll be creating this app over the course of four Try It Out exercises:

Laying the Foundations of the Journal App: In the first section you add the path_provider and intl packages to your pubspec.yaml file and set up the home.dart page with basic structure widgets.

Creating the Journal Database Classes: The second exercise focuses on creating the database.dart file with your classes to handle the file routines, database parsing, and Journal classes.

Creating the Journal Entry Page: The third exercise builds the edit_entry.dart file to handle creating and editing journal entries and selecting dates from a date picker.

Finishing the Journal Home Page: The fourth exercise finishes the home.dart page, which relies on the database.dart file by adding logic to build the ListView and save, read, sort data, and pass data to the edit journal page.

Figure 13.2 shows a high‐level view of the journal app detailing how the database classes are used in the Home and Edit pages.

Schematic of Journal app database classes' relationship to the Home and Edit pages.
FIGURE 13.2: Journal app database classes’ relationship to the Home and Edit pages

TRY IT OUT   LAYING THE FOUNDATIONS OF THE JOURNAL APP

In this series of steps, you’ll do some essential setup for the journal app, which you’ll build on over the remaining Try It Out sections in this chapter.

  1. Create a new Flutter project and name it ch13_local_persistence. “Creating a Starter Project Template.” For this project, you need to create only the pages and classes folders.
  2. Open the pubspec.yaml file to add resources. In the dependencies: section, add the path_provider: ^1.1.0 and intl: ^0.15.8 declarations. Note that your version might be higher.dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^0.1.2 path_provider: ^1.1.0 intl: ^0.15.8
  3. Click the Save button. Depending on the editor you are using, it automatically runs flutter packages get, and once finished, it will show a message of Process finished with exit code 0. If it does not automatically run the command for you, open the Terminal window (located at the bottom of your editor) and type flutter packages get.
  4. Open the main.dart file. Add to the ThemeData the bottomAppBarColor property and set the color to Colors.blue.return MaterialApp( debugShowCheckedModeBanner: false, title: 'Local Persistence', theme: ThemeData( primarySwatch: Colors.blue, bottomAppBarColor: Colors.blue, ), home: Home(), );
  5. Open the home.dart file and add to the body a FutureBuilder(). The FutureBuilder() initialData property is an empty List created with the open and close square brackets ([]). The future property calls the _loadJournals() Future method that you create in the “Finishing the Journal Home Page” exercise. For the builder property, you return a CircularProgressIndicator() if the snapshot.hasData is false, meaning no data has returned yet. Otherwise, you call the _buildListViewSeparated(snapshot) method to build the ListView showing the journal entries.As you learned in the “Retrieving Data with the FutureBuilder” section, the AsyncSnapshot returns a snapshot of the data. The snapshot is immutable, meaning it’s read‐only.body: FutureBuilder( initialData: [], future: _loadJournals(), builder: (BuildContext context, AsyncSnapshot snapshot) { return !snapshot.hasData ? Center(child: CircularProgressIndicator()) : _buildListViewSeparated(snapshot); }, ),
  6. After the body property, add the bottomNavigationBar property and set it to a BottomAppBar(). Set the shape to a CircularNotchedRectangle() and set the child to a Padding of 24.0 pixels. Add the floatingActionButtonLocation property and set it to FloatingActionButtonLocation.centerDocked.bottomNavigationBar: BottomAppBar( shape: CircularNotchedRectangle(), child: Padding(padding: const EdgeInsets.all(24.0)), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
  7. Add the floatingActionButton property and set it to a FloatingActionButton(). Normally the BottomAppBar() is used to show a row of widgets to make a selection, but for our app you are using it for aesthetics looks by using it with the FloatingActionButton to show a notch. The FloatingActionButton is responsible for adding new journal entries. Set the FloatingActionButton() child property to Icon(Icons.add) to show a plus sign that conveys the action to add a new journal entry.floatingActionButton: FloatingActionButton( tooltip: 'Add Journal Entry', child: Icon(Icons.add), ),
  8. Set the FloatingActionButton() onPressed property as an async callback that calls the _addOrEditJournal() method. Add a call to the _addOrEditJournal() method that takes three arguments: addindex, and journal. In “Finishing the Journal Home Page,” you’ll create the method that relies on the database.dart file creation.When the user taps this button, it’s to add a new entry, which is why you pass the arguments add as trueindex as ‐1, and journal as a blank Journal (class) entry. Because you also use the same method to edit an entry (user taps the ListView, covered in the final exercise), you would pass the arguments add as falseindex as the entry index from the ListView, and journal as the Journal selected.bottomNavigationBar: BottomAppBar( shape: CircularNotchedRectangle(), child: Padding(padding: const EdgeInsets.all(24.0)), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, floatingActionButton: FloatingActionButton( tooltip: 'Add Journal Entry', child: Icon(Icons.add), onPressed: () { _addOrEditJournal(add: true, index: -1, journal: Journal()); }, ),“Setting the FloatingActionButton() onPressed property as an async callback that calls the _addOrEditJournal() method. Adding a call to the addOrEditJournal() method that takes three arguments: add, index, and journal.”

HOW IT WORKS

You declared the path_provider and intl packages to your pubspec.yaml file. The path_provider gives you access to the local iOS and Android filesystem locations, and the intl gives you the ability to format dates. You added to the body property the FutureBuilder that calls a Future async method to return data, and you’ll implement it in the fourth exercise, “Finishing the Journal Home Page.”

You added a BottomAppBar and used the FloatingActionButtonLocation to dock the FloatingActionButton on the bottom center. The FloatingActionButton onPressed() event is marked as async to call the _addOrEditJournal() method to add a new journal entry. You’ll implement the _addOrEditJournal() method in “Finishing the Journal Home Page.”

ADDING THE JOURNAL DATABASE CLASSES

You’ll create four separate classes to handle the database routines and serialization to manage the journal data. Each class is responsible for handling specific code logic, resulting in code reusability. Collectively the database classes are responsible for writing (saving), reading, encoding, and decoding JSON objects to and from the JSON file.

  • The DatabaseFileRoutines class handles getting the path to the device’s local documents directory and saving and reading the database file by using the File class. The File class is used by importing the dart:io library, and to obtain the documents directory path, you import the path_provider package.
  • The Database class handles decoding and encoding the JSON objects and converting them to a List of journal entries. You call databaseFromJson to read and parse from JSON objects. You call databaseToJson to save and parse to JSON objects. The Database class returns the journal variable consisting of a List of Journal classes, List<Journal>. The dart:convert library is used to decode and encode JSON objects.
  • The Journal class handles decoding and encoding the JSON objects for each journal entry. The Journal class contains the iddatemood, and note journal entry fields stored as Strings.
  • The JournalEdit class handles the passing of individual journal entries between pages. The JournalEdit class contains the action and journal variables. The action variable is used to track whether the Save or Cancel button is pressed. The journal variable contains the individual journal entry as a Journal class containing the iddatemood, and note variables.

TRY IT OUT   CREATING THE JOURNAL DATABASE CLASSES

In this section, you’ll create the DatabaseFileRoutinesDatabaseJournal, and JournalEdit classes. Note that the default constructors use curly brackets ({}) to implement named parameters.

  1. Create a new Dart file under the classes folder. Right‐click the classes folder, select New ➪ Dart File, enter database.dart, and click the OK button to save.
  2. Import the path_provider.dart package and the dart:io and dart:convert libraries. Add a new line and create the DatabaseFileRoutines class.import 'package:path_provider/path_provider.dart'; // Filesystem locations import 'dart:io'; // Used by File import 'dart:convert'; // Used by json class DatabaseFileRoutines { }
  3. Inside the DatabaseFileRoutines class, add the _localPath async method that returns a Future<String>, which is the documents directory path.Future<String> get _localPath async { final directory = await getApplicationDocumentsDirectory(); return directory.path; }
  4. Add the _localFile async method that returns a Future<File> with the reference to the local_persistence.json file, which is the path, combined with the filename.Future<File> get _localFile async { final path = await _localPath; return File('$path/local_persistence.json'); }
  5. Add the readJournals() async method that returns a Future<String> containing the JSON objects. You’ll use a try‐catch just in case there is an issue with reading the file.Future<String> readJournals() async { try { } catch (e) { } }
  6. Use file.existsSync() to check whether the file exists; if not, you create it by calling the writeJournals('{"journals": []}') method by passing it an empty journals object. Next, file.readAsString() is called to load the contents of the file.Future<String> readJournals() async { try { final file = await _localFile; if (!file.existsSync()) { print("File does not Exist: ${file.absolute}"); await writeJournals('{"journals": []}'); } // Read the file String contents = await file.readAsString(); return contents; } catch (e) { print("error readJournals: $e"); return ""; } }
  7. Add the writeJournals(String json) async method returning a Future<File> to save the JSON objects to file.Future<File> writeJournals(String json) async { final file = await _localFile; // Write the file return file.writeAsString('$json'); }
  8. Following the DatabaseFileRoutines class, create two methods that call the Database class to handle the JSON decode and encode for the entire database. Create the databaseFromJson(String str) method returning a Database by passing it the JSON string. By using json.decode(str), it parses the JSON string and returns a JSON object.// To read and parse from JSON data - databaseFromJson(jsonString); Database databaseFromJson(String str) { final dataFromJson = json.decode(str); return Database.fromJson(dataFromJson); }
  9. Create the databaseToJson(Database data) method returning a String. By using the json.encode(dataToJson), it parses the values to a JSON string.// To save and parse to JSON Data - databaseToJson(jsonString); String databaseToJson(Database data) { final dataToJson = data.toJson(); return json.encode(dataToJson); }
  10. Create the Database class, and the first item to declare is the journal variable of a List<Journal> type, meaning it contains a list of journals. The Journal class contains each record, and you’ll create it in step 13. Declare the Database constructor with the named parameter this.journal variable. Note you are using curly brackets ({}) to declare the constructor named parameter.class Database { List<Journal> journal; Database({ this.journal, }); }
  11. To retrieve and map the JSON objects to a List<Journal> (list of Journal classes), create the factory Database.fromJson() named constructor. Note that the factory constructor does not always create a new instance but might return an instance from a cache. The constructor takes the argument of Map<String, dynamic>, which maps the String key with a dynamic value, the JSON key/value pair. The constructor returns the List<Journal> by taking the JSON 'journals' key objects and mapping it from the Journal class that parses the JSON string to the Journal object containing each field such as the iddatemood, and note.factory Database.fromJson(Map<String, dynamic> json) => Database( journal: List<Journal>.from(json["journals"].map((x) => Journal.fromJson(x))), );
  12. To convert the List<Journal> to JSON objects, create the toJson method that parses each Journal class to JSON objects.Map<String, dynamic> toJson() => { "journals": List<dynamic>.from(journal.map((x) => x.toJson())), };The following is the entire Database class:class Database { List<Journal> journal; Database({ this.journal, }); factory Database.fromJson(Map<String, dynamic> json) => Database( journal: List<Journal>.from(json["journals"].map((x) => Journal.fromJson(x))), ); Map<String, dynamic> toJson() => { "journals": List<dynamic>.from(journal.map((x) => x.toJson())), }; }
  13. Create the Journal class and declare as String types the iddatemood, and note variables. Declare the Journal constructor with the named parameters this.idthis.datethis.mood, and this.note variables. Note that you are using curly ({}) brackets to declare the constructor named parameters.class Journal { String id; String date; String mood; String note; Journal({ this.id, this.date, this.mood, this.note, }); }
  14. To retrieve and convert the JSON object to a Journal class, create the factory Journal.fromJson() named constructor. The constructor takes the argument of Map<String, dynamic>, which maps the String key with a dynamic value, the JSON key/value pair.factory Journal.fromJson(Map<String, dynamic> json) => Journal( id: json["id"], date: json["date"], mood: json["mood"], note: json["note"], );
  15. To convert the Journal class to a JSON object, create the toJson() method that parses the Journal class to a JSON object.Map<String, dynamic> toJson() => { "id": id, "date": date, "mood": mood, "note": note, };The following is the entire Journal class:class Journal { String id; String date; String mood; String note; Journal({ this.id, this.date, this.mood, this.note, }); factory Journal.fromJson(Map<String, dynamic> json) => Journal( id: json["id"], date: json["date"], mood: json["mood"], note: json["note"], ); Map<String, dynamic> toJson() => { "id": id, "date": date, "mood": mood, "note": note, }; }
  16. Create the JournalEdit class that is responsible for passing the action and a journal entry between pages. Add a String action variable and a Journal journal variable. Add the default JournalEdit constructor.class JournalEdit { String action; Journal journal; JournalEdit({this.action, this.journal}); }

HOW IT WORKS

You created a database.dart file containing four classes to handle the local persistence serialization and deserialization.

The DatabaseFileRoutines class handles locating the device’s local document directory path through the path_provider package. You used the File class to handle the saving and reading of the database file by importing the dart:io library. The file is text‐based containing the key/value pair of JSON objects.

The Database class uses json.encode and json.decode to serialize and deserialize JSON objects by importing the dart:convert library. You use the Database.fromJson named constructor to retrieve and map the JSON objects to a List<Journal>. You use the toJson() method to convert the List<Journal> to JSON objects.

The Journal class is responsible for tracking individual journal entries through the String iddatemood, and note variables. You use the Journal.fromJson() named constructor to take the argument of Map<String, dynamic>, which maps the String key with a dynamic value, the JSON key/value pair. You use the toJson() method to convert the Journal class into a JSON object.

The JournalEdit class is used to pass data between pages. You declared a String action variable and a Journal journal variable. The action variable passes an action to 'Save' or 'Cancel', editing an entry. You learn’ll to use the JournalEdit class in the “Creating the Journal Entry Page” and “Finishing the Home Page” exercises. The journal variable passes the journal entry values.

ADDING THE JOURNAL ENTRY PAGE

The entry page is responsible for adding and editing a journal entry. You might ask, how does it know when to add or edit a current entry? You created the JournalEdit class in the database.dart file for this reason—to allow you to reuse the same page for multiple purposes. The entry page extends a StatefulWidget with the constructor (Table 13.4) having the three arguments addindex, and journalEdit. Note that the index argument is used to track the selected journal entry location in the journal database list from the Home page. However, if a new journal entry is created, it does not exist in the list yet, so a value of ‐1 is passed instead. Any index numbers zero and up would mean the journal entry already exists in the list.

TABLE 13.4: EditEntry Class Constructor Arguments

VARIABLEDESCRIPTION AND VALUE
final bool addIf the add variable value is true, it means you are adding a new journal. If the value is false, you are editing a journal entry.
final int indexIf the index variable value is ‐1, it means you are adding a new journal entry. If the value is 0 or greater, you are editing a journal entry, and you need to track the index position in the List<Journal>.
final JournalEdit journalEdit
String action
Journal journal
The JournalEdit class passes two values. The action value is either 'Save' or 'Cancel'. The journal variable passes the entire Journal class, consisting of the iddatemood, and note values.

The entry page has Cancel and Save buttons that call an action with the onPressed() method (Table 13.5). The onPressed() method sends back to the Home page the JournalEdit class with appropriate values depending on which button is pressed.

TABLE 13.5: Save or Cancel FlatButton

ONPRESSED()RESULT
CancelThe JournalEdit action variable is set to 'Cancel', and the class is passed back to the Home page with Navigator.pop(context, _journalEdit).
The Home page receives the values and does not take any action since the editing was canceled.
SaveThe JournalEdit action variable is set to 'Save', and the journal variable is set with the current Journal class values, the iddatemood, and note. If the add value is equal to true, meaning adding a new entry, a new id value is generated. If the add value is equal to false, meaning editing an entry, the current journal id is used.
The Home page receives the values and executes the 'Save' logic with received values.

To make it easy for the user to select a date, you use the built‐in date picker that presents a calendar. To show the calendar, you call the showDatePicker()function (Table 13.6) and pass four arguments: contextinitialDatefirstDate, and lastDate (Figure 13.3).

TABLE 13.6: showDatePicker

PROPERTYVALUE
contextYou pass the BuildContext as the context.
initialDateYou pass the journal date that is highlighted and selected in the calendar.
firstDateThe oldest date range available to be picked in the calendar from today’s date.
lastDateThe newest date range available to be picked in the calendar from today’s date.
Screnshot of date picker calendar.
FIGURE 13.3: Date picker calendar

Once the date is retrieved, you’ll use the DateFormat.yMMMEd() constructor to show it in Sun, Jan 13, 2018 format. If you would like to show a time picker, call the showTimePicker() method and pass the context and initialTime arguments.

Now let’s use a different approach without a Form but use the TextField with a TextEditingController. You’ll learn how to use the TextField TextInputAction with the FocusNode to customize the keyboard action button to execute a custom action (Figure 13.4). The keyboard action button is located to the right of the spacebar. You’ll also learn how to customize the TextField capitalization options by using TextCapitalization that configures how the keyboard capitalizes words, sentences, and characters; the settings are wordssentencescharacters, or none (default).

Screenshot of keyboard action button for iOS and Android
FIGURE 13.4: Keyboard action button for iOS and Android

TRY IT OUT   CREATING THE JOURNAL ENTRY PAGE

In this section, you’ll create the EditEntry StatefulWidget with the constructor taking the arguments addindex, and journalEdit. Note that the default constructors use the curly brackets ({}) to implement named parameters. The following graphic is the final journal entry page that you’ll create.

“Screenshot of creating the EditEntry StatefulWidget with the constructor taking the arguments add, index, and journalEdit . Note that the default constructors use the curly brackets ({} ) to implement named parameters. This graphic is the final journal entry page.”
  1. Create a new Dart file under the pages folder. Right‐click the pages folder, select New ➪ Dart File, enter edit_entry.dart, and click the OK button to save.
  2. Import the material.dart class, the database.dart class, the intl.dart package, and the dart:math library. Add a new line and create the EditEntry class that extends a StatefulWidget.import 'package:flutter/material.dart'; import 'package:ch13_local_persistence/classes/database.dart'; import 'package:intl/intl.dart'; // Format Dates import 'dart:math'; // Random() numbers class EditEntry extends StatefulWidget { @override _EditEntryState createState() => _EditEntryState(); } class _EditEntryState extends State<EditEntry> { @override Widget build(BuildContext context) { return Container(); } }
  3. After the class EditEntry extends StatefulWidget { and before the @override, add the three variables bool addint index, and JournalEdit journalEdit and mark them as final.class EditEntry extends StatefulWidget { final bool add; final int index; final JournalEdit journalEdit; @override _EditEntryState createState() => _EditEntryState(); }
  4. Add the EditEntry constructor with Key keythis.addthis.index, and this.journalEdit as named parameters by enclosing them in curly brackets ({}).class EditEntry extends StatefulWidget { final bool add; final int index; final JournalEdit journalEdit; const EditEntry({Key key, this.add, this.index, this.journalEdit}) : super(key: key); @override _EditEntryState createState() => _EditEntryState(); }
  5. Modify the _EditEntryState class and add the private JournalEdit _journalEditString _title, and DateTime _selectedDate variables. Note that the private _journalEdit variable is populated from the JournalEdit class value passed to the EditEntry constructor.class _EditEntryState extends State<EditEntry> { JournalEdit _journalEdit; String _title; DateTime _selectedDate; @override Widget build(BuildContext context) { return Container(); } }
  6. The mood and note use the TextField widget, which requires the TextEditingController to access and modify the values. Add _moodController and _noteController TextEditingController variables and initialize them with the TextEditingController() constructor. Note that this controller treats a null value as an empty string.class _EditEntryState extends State<EditEntry> { JournalEdit _journalEdit; String _title; DateTime _selectedDate; TextEditingController _moodController = TextEditingController(); TextEditingController _noteController = TextEditingController(); @override Widget build(BuildContext context) { return Container(); } }
  7. Declare the _moodFocus and _noteFocus FocusNode variables and initialize them with the FocusNode() constructor. You’ll use the FocusNode with the TextInputAction to customize the keyboard action button in steps 30 and 31.class _EditEntryState extends State<EditEntry> { JournalEdit _journalEdit; String _title; DateTime _selectedDate; TextEditingController _moodController = TextEditingController(); TextEditingController _noteController = TextEditingController(); FocusNode _moodFocus = FocusNode(); FocusNode _noteFocus = FocusNode(); @override Widget build(BuildContext context) { return Container(); } }
  8. Override the initState(), and let’s initialize the variables with values passed to the EditEntry constructor and make sure you add the super.initState().@override void initState() { super.initState(); }
  9. Initialize the _journalEdit variable by using the JournalEdit class constructor by defaulting the action to 'Cancel' and the journal to widget.journalEdit.journal value. Note that you use the widget to access the values from the EditEntry constructor. Also, note that you access the individual journal entry from the JournalEdit class by using the dot operator and then choosing the journal variable._journalEdit = JournalEdit(action: 'Cancel', journal: widget.journalEdit.journal);
  10. Initialize the _title variable by using a ternary operator to check whether widget.add is true. If it is, set the value to 'Add', and if false, set the value to 'Edit'. By using the _title variable, you customize the AppBar‘s title to the action the user is taking. It’s the little details that make an app great._title = widget.add ? 'Add' : 'Edit';
  11. Initialize the _journalEdit.journal variable from the widget.journalEdit.journal variable._journalEdit.journal = widget.journalEdit.journal;
  12. To populate the entry fields on the page, add an if‐else statement. If the widget.add value is true, meaning adding a new journal record, then initialize the _selectedDate variable with the current date by using the DateTime.now() constructor and initialize the _moodController.textand _noteController.text to an empty string. If the widget.add value is false, meaning editing a current journal record, then initialize the _selectedDate variable with the _journalEdit.journal.date and use the DateTime.parse to convert the date from String to a DateTime format. Also initialize the _moodController.text with the _journalEdit.journal.mood and the _noteController.text with the _journalEdit.journal.note. When you override the initState() method, make sure you start the method with a call to super.initState().@override void initState() { super.initState(); _journalEdit = JournalEdit(action: 'Cancel', journal: widget.journalEdit.journal); _title = widget.add ? 'Add' : 'Edit'; _journalEdit.journal = widget.journalEdit.journal; if (widget.add) { _selectedDate = DateTime.now(); _moodController.text = ''; _noteController.text = ''; } else { _selectedDate = DateTime.parse(_journalEdit.journal.date); _moodController.text = _journalEdit.journal.mood; _noteController.text = _journalEdit.journal.note; } }
  13. Override dispose(), and let’s dispose the two TextEditingController and FocusNode; make sure you add super.dispose(). When you override the dispose() method, make sure you end the method with a call to super.dispose().@override dispose() { _moodController.dispose(); _noteController.dispose(); _moodFocus.dispose(); _noteFocus.dispose(); super.dispose(); }
  14. Add the _selectDate(DateTime selectedDate) async method that returns a Future<DateTime>. This method is responsible for calling the Flutter built‐in showDatePicker() that presents the user with a popup dialog displaying a Material Design calendar to choose dates.// Date Picker Future<DateTime> _selectDate(DateTime selectedDate) async { }
  15. Add the DateTime _initialDate variable and initialize it with the selectedDate variable passed in the constructor.DateTime _initialDate = selectedDate;
  16. Add a final DateTime _pickedDate (the date the user picks from the calendar) variable and initialize it by calling the await showDatePicker() constructor. Pass the contextinitialDatefirstDate, and lastDate arguments. Note that for the firstDate you use today’s date and subtract 365 days, and for the lastDate, you add 365 days, which tells the calendar a selectable dates range.final DateTime _pickedDate = await showDatePicker( context: context, initialDate: _initialDate, firstDate: DateTime.now().subtract(Duration(days: 365)), lastDate: DateTime.now().add(Duration(days: 365)), );
  17. Add an if statement that checks that the _pickedDate (the date the user picked from the calendar) variable does not equal null, meaning the user tapped the calendar’s Cancel button. If the user did pick a date, then modify the selectedDate variable by using the DateTime() constructor and pass the _pickedDate yearmonth, and day. For the time, pass the _initialDate hourminutesecondmillisecond, and microsecond. Note that since you are only changing the date and not the time, you use the original’s created date time.if (_pickedDate != null) { selectedDate = DateTime( _pickedDate.year, _pickedDate.month, _pickedDate.day, _initialDate.hour, _initialDate.minute, _initialDate.second, _initialDate.millisecond, _initialDate.microsecond); }
  18. Add a return statement to send back the selectedDate.return selectedDate;The following is the entire _selectDate() method:// Date Picker Future<DateTime> _selectDate(DateTime selectedDate) async { DateTime _initialDate = selectedDate; final DateTime _pickedDate = await showDatePicker( context: context, initialDate: _initialDate, firstDate: DateTime.now().subtract(Duration(days: 365)), lastDate: DateTime.now().add(Duration(days: 365)), ); if (_pickedDate != null) { selectedDate = DateTime( _pickedDate.year, _pickedDate.month, _pickedDate.day, _initialDate.hour, _initialDate.minute, _initialDate.second, _initialDate.millisecond, _initialDate.microsecond); } return selectedDate; }
  19. In the Widget build() method, replace the Container() with the UI widgets Scaffold and AppBar, and for the body property add a SafeArea() and SingleChildScrollView() with the child property as a Column(). Note that the AppBar title uses the Text widget with the _title variable to customize the title with either Add or Edit Entry.@override Widget build(BuildContext context) {]></line> return Scaffold(] appBar: AppBar(] title: Text('$_title Entry'), automaticallyImplyLeading: false, ), body: SafeArea( child: SingleChildScrollView( padding: EdgeInsets.all(16.0), child: Column( children: <Widget>[ ], ), ), ), ); }
  20. Add to the Column children a FlatButton widget that is used to show the formatted selected date, and when the user taps the button, it presents the calendar. Set the FlatButton padding property to EdgeInsets.all(0.0) to remove padding for better aesthetics and add to the child property a Row() widget.FlatButton( padding: EdgeInsets.all(0.0), child: Row( children: <Widget>[ ], ), ),“Screenshot of adding to the Column children a FlatButton widget that is used to show the formatted selected date, and when the user taps the button, it presents the calendar. Setting the FlatButton padding property to EdgeInsets.all(0.0) to remove padding for better aesthetics and adding to the child property a Row() widget.”
  21. Add to the Row children property the Icons.calendar_day Icon with a size of 22.0 and a color property set to Colors.black54.Icon( Icons.calendar_today, size: 22.0, color: Colors.black54, ),
  22. Add a SizedBox with a width property set to 16.0 to add a spacer.SizedBox(width: 16.0,),
  23. Add a Text widget and format the _selectedDate with the DateFormat.yMMMEd() constructor.Text(DateFormat.yMMMEd().format(_selectedDate), style: TextStyle( color: Colors.black54, fontWeight: FontWeight.bold), ),
  24. Add the Icons.arrow_drop_down Icon with the color property set to Colors.black54.Icon( Icons.arrow_drop_down, color: Colors.black54, ),
  25. Add the onPressed() callback and mark it async since calling the calendar is a Future event.onPressed: () async { },
  26. Add to onPressed() the FocusScope.of().requestFocus() method call to dismiss the keyboard if any of the TextField widgets have focus. (This step is optional, but I wanted to show you how it’s accomplished.)FocusScope.of(context).requestFocus(FocusNode());
  27. Add a DateTime _pickerDate variable initialized by calling the await _selectDate(_selectedDate) Future method, which is why you add the await keyword. You added this method in step 14.
  28. Add setState() and inside the call modify the _selectedDate variable with the _pickerDate value, which is the date selected from the calendar.DateTime _pickerDate = await _selectDate(_selectedDate); setState(() { _selectedDate = _pickerDate; });The following is the full FlatButton widget code:FlatButton( padding: EdgeInsets.all(0.0), child: Row( children: <Widget>[ Icon( Icons.calendar_today, size: 22.0, color: Colors.black54, ), SizedBox(width: 16.0,), Text(DateFormat.yMMMEd().format(_selectedDate), style: TextStyle( color: Colors.black54, fontWeight: FontWeight.bold), ), Icon( Icons.arrow_drop_down, color: Colors.black54, ), ], ), onPressed: () async { FocusScope.of(context).requestFocus(FocusNode()); DateTime _pickerDate = await _selectDate(_selectedDate); setState(() { _selectedDate = _pickerDate; }); }, ),
  29. Now it’s time to add the two TextField widgets for the mood and note fields. How do you set which TextField belongs to the mood or note? It’s the controller, of course.For the mood TextField, set the controller to _moodController, and set autofocus to true to automatically set the focus and show the keyboard when the page opens.TextField( controller: _moodController, autofocus: true, ),
  30. Set textInputAction to TextInputAction.next telling the keyboard action button to move to the next field.textInputAction: TextInputAction.next,
  31. Set the focusNode to _moodFocus and textCapitalization to TextCapitalization.words, meaning every word is automatically capitalized.focusNode: _moodFocus, textCapitalization: TextCapitalization.words,
  32. Set the decoration to InputDecoration with the labelText set to 'Mood' and the icon set to Icons.mood.decoration: InputDecoration( labelText: 'Mood', icon: Icon(Icons.mood), ),
  33. For the onSubmitted property, enter the argument name as submitted and call the FocusScope.of(context).requestFocus(_noteFocus) to have the keyboard action button change the focus to the note TextField. Note that I named the argument submitted, but it can be any name, like submittedValue or moodValue.onSubmitted: (submitted) { FocusScope.of(context).requestFocus(_noteFocus); },The following is the full mood TextField widget:TextField( controller: _moodController, autofocus: true, textInputAction: TextInputAction.next, focusNode: _moodFocus, textCapitalization: TextCapitalization.words, decoration: InputDecoration( labelText: 'Mood', icon: Icon(Icons.mood), ), onSubmitted: (submitted) { FocusScope.of(context).requestFocus(_noteFocus); }, ),“Screenshot of the onSubmitted property, entering the argument name as submitted and calling the FocusScope .of(context).requestFocus(_noteFocus) to have the keyboard action button change the focus to the note TextField .”
  34. For the note TextField, set the controller to _noteController, and set autofocus to true to automatically set the focus and show the keyboard when the page opens.TextField( controller: _noteController, ),
  35. Set the textInputAction to TextInputAction.newline telling the keyboard action button to insert a new line in the TextField.textInputAction: TextInputAction.newline,
  36. Set the focusNode to _noteFocus and textCapitalization to TextCapitalization.sentences, meaning every first word of a sentence is automatically capitalized.focusNode: _noteFocus, textCapitalization: TextCapitalization.sentences,
  37. Set the decoration to InputDecoration with the labelText set to 'Note' and the icon set to Icons.subject.decoration: InputDecoration( labelText: 'Note', icon: Icon(Icons.subject), ),
  38. Set the maxLines property to null, allowing the TextField to grow vertically to show the entire contents of the note. Using this technique is a great way to have a TextField automatically grow to the size of the content without writing any code logic.maxLines: null,The following is the full note TextField widget:TextField( controller: _noteController, textInputAction: TextInputAction.newline, focusNode: _noteFocus, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( labelText: 'Note', icon: Icon(Icons.subject), ), maxLines: null, ),“Screenshot of setting the maxLines property to null, allowing the TextField to grow vertically to depict the entire contents of the note. Using this technique is a great way to have a TextField automatically grow to the size of the content without writing any code logic.”
  39. The last part for the entry page is to add Cancel and Save buttons. Add a Row and set mainAxisAlignment to MainAxisAlignment.end to align buttons to the right side of the page.Row( mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ ], ),
  40. Edit the Row children and add a FlatButton with the child set to a Text widget to display 'Cancel'. Set the color property to Colors.grey.shade100, making the button not the main action focus.FlatButton( child: Text('Cancel'), color: Colors.grey.shade100, ),
  41. For the onPressed property, modify the _journalEdit.action to 'Cancel' and call the Navigator.pop(context, _journalEdit) to dismiss the entry form and pass the value back to the calling page. You’ll handle this action in the last Try It Out in this chapter (“Finishing the Journal Home Page”).FlatButton( child: Text('Cancel'), color: Colors.grey.shade100, onPressed: () { _journalEdit.action = 'Cancel'; Navigator.pop(context, _journalEdit); }, ),
  42. Add a SizedBox with the width set to 8.0 to place a spacer between the two buttons.SizedBox(width: 8.0),
  43. Add the second FlatButton with the child set to a Text widget to display 'Save'. Set the color property to Colors.lightGreen.shade100, making the button the main action focus.FlatButton( child: Text('Save'), color: Colors.lightGreen.shade100, ),
  44. For the onPressed property, modify the _journalEdit.action to 'Save'.onPressed: () { _journalEdit.action = 'Save'; },
  45. Since you are saving the entry, declare a String _id variable and use the ternary operator to check that the widget.add variable is set to true and use the Random().nextInt(9999999) to generate a random number. If the widget.add is false, then use the current _journalEdit.journal.id since you are editing an existing entry.Note that nextInt() sets the maximum number range from zero, and in our case, you set the maximum to 9999999. Note that for our purposes this works great, but in a production environment, I suggest you use a UUID, which is a 128‐bit number that includes alphanumeric characters. A sample UUID looks like this: 409fg342‐h34c‐25c8‐b311‐51874523574e.String _id = widget.add ? Random().nextInt(9999999).toString() : _journalEdit.journal.id;
  46. Modify the _journalEdit.journal value by using the Journal() class constructor and pass the id property with the _id variable, the date with the _selectedDate.toString() (date is saved as a String), the mood with the _moodController.text, and the note with the _noteController.text._journalEdit.journal = Journal( id: _id, date: _selectedDate.toString(), mood: _moodController.text, note: _noteController.text, );
  47. Call the Navigator.pop(context, _journalEdit) to dismiss the entry form and pass the value back to the calling page. You handle receiving this action in the “Finishing the Journal Home Page” exercise.FlatButton( child: Text('Save'), color: Colors.lightGreen.shade100, onPressed: () { _journalEdit.action = 'Save'; String _id = widget.add ? Random().nextInt(9999999).toString() : _journalEdit.journal.id; _journalEdit.journal = Journal( id: _id, date: _selectedDate.toString(), mood: _moodController.text, note: _noteController.text, ); Navigator.pop(context, _journalEdit); }, ),The following is the full Row widget code:Row( mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ FlatButton( child: Text('Cancel'), color: Colors.grey.shade100, onPressed: () { _journalEdit.action = 'Cancel'; Navigator.pop(context, _journalEdit); }, ), SizedBox(width: 8.0), FlatButton( child: Text('Save'), color: Colors.lightGreen.shade100, onPressed: () { _journalEdit.action = 'Save'; String _id = widget.add ? Random().nextInt(9999999).toString() : _journalEdit.journal.id; _journalEdit.journal = Journal( id: _id, date: _selectedDate.toString(), mood: _moodController.text, note: _noteController.text, ); Navigator.pop(context, _journalEdit); }, ), ], ),“Calling the Navigator.pop(context, _journalEdit) to dismiss the entry form and pass the value back to the calling page. Handling receiving this action in the “Finishing the Journal Home Page” exercise.”

HOW IT WORKS

You created the edit_entry.dart file with the EditEntry class extending a StatefulWidget to handle adding and editing journal entries. You customized the constructor to have the three arguments addindex, and journalEdit. The add variable is responsible for handling whether you are adding or modifying an entry; the index is ‐1 if adding a record or is the actual List index location if you are editing an entry. The journalEdit variable has an action value for 'Cancel' or 'Save' and the Journal class holding the journal entry values for the iddatemood, and note values.

The showDatePicker() function displays a popup dialog that contains the Material Design date picker. You pass the contextinitialDatefirstDate, and lastDate arguments to customize the pickable date range.

To format dates, you use the appropriate DateFormat named constructor. To further customize the date format, you can use the add_*() methods (substitute the * character with the format characters needed) to append and compound multiple formats.

The TextEditingController allows access to the value of the associated TextField widget. The TextField TextInputAction allows you to customize the device keyboard action button. The FocusNode is associated to the TextField widget, which allows you to set focus on the appropriate TextField programmatically. The TextCapitalization allows you to configure the TextField widget’s capitalization by using wordssentences, and characters.

The JournalEdit class tracks the entry action and values. It uses the action variable to track whether the Save or Cancel button is tapped. It uses the journal variable to hold the Journal class field values for editing or creating new journal entries. The Navigator.pop() method returns the JournalEdit class values to the Home page.

FINISHING THE JOURNAL HOME PAGE

The Home page is responsible for showing a list of journal entries. In Chapter 9, “Creating Scrolling Lists and Effects,” you learned how to use the ListView.builder, but for this app, you’ll learn how to use the ListView.separated constructor. By using the separated constructor, you have the same benefits of the builder constructor because the builders are called only for the children who are visible on the page. You might have noticed I said builders, because you use two of them, the standard itemBuilder for the List of children (journal entries) and the separatorBuilder to show a separator between children. The separatorBuilder is extremely powerful for customizing the separator; it could be an ImageIcon, or custom widget, but for our purposes, you’ll use a Divider widget. You’ll use the ListTile to format your list of journal entries and customize the leading property with a Column to show the date and day of the week, making it easier to spot individual entries (Figure 13.5).

Screenshot of Journal entry list.
FIGURE 13.5: Journal entry list

To delete journal entries, you’ll use a Dismissible, which you learned about in Chapter 11, “Applying Interactivity.” Keep in mind that for the Dismissible to work properly and delete the correct journal entry, you’ll set the key property to the journal entry id field by using the Key class, which takes a String value like Key(snapshot.data[index].id).

You’ll learn how to use the FutureBuilder widget, which works with a Future to retrieve the latest data without blocking the UI. You learned details in this chapter’s “Retrieving Data with the FutureBuilder” section.

You’ll use a Future to retrieve the journal entries. Retrieving the journal entries requires multiple steps, and to help you manage them, you’ll use the database.dart file classes that you created in the “Adding the Journal Database Classes” section of this chapter. The classes that you use are DatabaseFileRoutinesDatabaseJournal, and JournalEdit.

  1. You call the DatabaseFileRoutines calls to read the JSON file located in the device documents folder.
  2. You call the Database class to parse the JSON to a List format.
  3. You use the List sort function to sort entries by DESC date.
  4. The sorted List is returned to the FutureBuilder, and the ListView displays existing journal entries.

TRY IT OUT   FINISHING THE JOURNAL HOME PAGE

In this section, you’ll finish the Home page by adding methods to load, add, modify, and save journal entries. You’ll create a method to build and use the ListView.separated constructor to customize your list of journal entries. In the ListView itemBuilder, you’ll add a Dismissible to handle deleting journal entries.

You’ll import the database.dart file to utilize the database classes to help you to serialize the JSON objects.

  1. Import the edit_entry.dartdatabase.dart and intl.dart packages.import 'package:flutter/material.dart'; import 'package:ch13_local_persistence/pages/edit_entry.dart';import 'package:ch13_local_persistence/classes/database.dart'; import 'package:intl/intl.dart'; // Format Dates
  2. After the class _HomeState extends State<Home> {, add the Database _database variable. The _database variable holds the parsed JSON objects of the journal JSON object, which is your list of journal entries.class _HomeState extends State<Home> { Database _database;
  3. Add the _loadJournals() async method that returns a Future<List<Journal>>, which is a List of the Journal class entries.Future<List<Journal>> _loadJournals() async { }
  4. Add the await DatabaseFileRoutines().readJournals() call and add with the dot notation a call to then((journalsJson) {}).What exactly is this call to then()? It registers a callback to be called when the Future completes. What this means is that once the readJournals() method completes and returns the value, then() executes the code inside. Note that the journalsJson parameter receives the value from the JSON objects read from the saved local_persistence.json file located in the device local documents folder.await DatabaseFileRoutines().readJournals().then((journalsJson) { });
  5. Inside the then() callback, modify the _database variable with the value from the call to databaseFromJson(journalsJson).The databaseFromJson method in the database.dart class uses json.decode() to parse the JSON objects that are read from the saved file. Database.fromJson() is called, and it returns the JSON objects as a Dart List, which is extremely powerful. At this point, it’s clear how separating the code logic to handle your data into the database classes becomes useful and straightforward._database = databaseFromJson(journalsJson);
  6. Continue inside the then() callback, and let’s sort the journal entries by DESC date, with newer entries first and older last. Use _database.journal.sort() to compare dates and sort them._database.journal.sort((comp1, comp2) => comp2.date.compareTo(comp1.date));
  7. After the then() callback, add a new line with the return statement returning the variable _database.journal, which contains the sorted journal entries.return _database.journal;The following is the full _loadJournals() method:Future<List<Journal>> _loadJournals() async { await DatabaseFileRoutines().readJournals().then((journalsJson) { _database = databaseFromJson(journalsJson); _database.journal.sort((comp1, comp2) => comp2.date.compareTo(comp1.date)); }); return _database.journal; }
  8. Add the _addOrEditJournal() method that handles presenting the edit entry page to either add or modify a journal entry. You use Navigator.push() to present the entry page and wait for the result of the user’s actions. If the user pressed the Cancel button, nothing happens, but if they pressed Save, then you either add the new journal entry or save the changes to the current edited entry._addOrEditJournal() is an async method taking the named parameters of bool addint index, and Journal journal. Refer to Table 13.4 for the arguments description. Initiate the JournalEdit _journalEdit variable with the JournalEdit class with the action value set to an empty string and the journal value set to the journal variable that is passed in from the constructor.void _addOrEditJournal({bool add, int index, Journal journal}) async { JournalEdit _journalEdit = JournalEdit(action: '', journal: journal); }
  9. Add a new line; you are going to use the Navigator to pass the constructor values to the edit entry page by using the await keyword that passes the value back to the local _journalEdit variable. For the MaterialPageRoute builder, pass the constructor values to the EditEntry() class and set the fullscreenDialog property to true._journalEdit = await Navigator.push( context, MaterialPageRoute( builder: (context) => EditEntry( add: add, index: index, journalEdit: _journalEdit, ), fullscreenDialog: true ), );Once the edit entry page is dismissed, the switch statement executes next, and you’ll take appropriate action depending on the user’s selection. The switch statement evaluates the _journalEdit.action to check whether the Save button was pressed and then checks whether you are adding or saving the entry with an if‐else statement.switch (_journalEdit.action) { }
  10. Add the first switch case statement that checks for the 'Save' value.If the add variable is set to true, meaning you are adding a new entry, then you use the setState() and call the _database.journal.add(_journalEdit.journal) by passing the journal values.switch (_journalEdit.action) { case 'Save': if (add) { setState(() { _database.journal.add(_journalEdit.journal); }); } break; }
  11. If the add variable is set to false, meaning you are saving an existing entry, then you use the setState() and modify the value of the _database.journal[index] = _journalEdit.journal. You are replacing the values from the current _database.journal[index] selected journal entry by the index value and replacing it with the _journalEdit.journal value passed from the edit entry page.switch (_journalEdit.action) { case 'Save': if (add) { setState(() { _database.journal.add(_journalEdit.journal); }); } else { setState(() { _database.journal[index] = _journalEdit.journal; }); } break; }
  12. To save the journal entry values to the device local storage documents directory, you call DatabaseFileRoutines().writeJournals(databaseToJson(_database)). Add the second case statement that checks for the 'Cancel' value, but there’s no need to add any actions since the user canceled editing.switch (_journalEdit.action) { case 'Save': if (add) { setState(() { _database.journal.add(_journalEdit.journal); }); } else { setState(() { _database.journal[index] = _journalEdit.journal; }); } DatabaseFileRoutines().writeJournals(databaseToJson(_database)); break; case 'Cancel': break; }
  13. Add the default check just in case something else happened, but you also do not take any further action.switch (_journalEdit.action) { case 'Save': if (add) { setState(() { _database.journal.add(_journalEdit.journal); }); } else { setState(() { _database.journal[index] = _journalEdit.journal; }); } DatabaseFileRoutines().writeJournals(databaseToJson(_database)); break; case 'Cancel': break; default: break; }The following is the full _addOrEditJournal method:void _addOrEditJournal({bool add, int index, Journal journal}) async { JournalEdit _journalEdit = JournalEdit(action: '', journal: journal); _journalEdit = await Navigator.push( context, MaterialPageRoute( builder: (context) => EditEntry( add: add, index: index, journalEdit: _journalEdit, ), fullscreenDialog: true ), ); switch (_journalEdit.action) { case 'Save': if (add) { setState(() { _database.journal.add(_journalEdit.journal); }); } else { setState(() { _database.journal[index] = _journalEdit.journal; }); } DatabaseFileRoutines().writeJournals(databaseToJson(_database)); break; case 'Cancel': break; default: break; } }
  14. Add the _buildListViewSeparated(AsyncSnapshot snapshot) method that takes the AsyncSnapshot parameter, which is the List of journal entries. The method is called from the FutureBuilder() in the body property. The way to read the journal entry List is by accessing the snapshot data property by using snapshot.data. Each journal entry is accessed by using the index like snapshot.data[index]. To access each field, you use the snapshot.data[index].date or snapshot.data[index].mood and so on.Widget _buildListViewSeparated(AsyncSnapshot snapshot) { }
  15. The method returns a ListView by using the separated() constructor. Set the itemCount property to the snapshot.data.length and set the itemBuilder property to (BuildContext context, int index).Widget _buildListViewSeparated(AsyncSnapshot snapshot) { return ListView.separated( itemCount: snapshot.data.length, itemBuilder: (BuildContext context, int index) { }, ); }
  16. Inside the itemBuilder, initialize the String _titleDate with the DateFormat.yMMMD() constructor using the format() with the snapshot.data[index].date. Since the data is in a String format, use the DateTime.parse() constructor to convert it to a date.String _titleDate = DateFormat.yMMMd().format(DateTime.parse(snapshot.data[index].date));
  17. Initialize the String _subtitle with the mood and note fields by using string concatenation and separate them with a blank line by using the '\n' character.String _subtitle = snapshot.data[index].mood + "\n" + snapshot.data[index].note;
  18. Add the return Dismissible(), and you’ll complete it in step 20.return Dismissible();
  19. Add the separatorBuilder that handles the separator line between journal entries by using a Divider() with the color property set to Colors.grey.Widget _buildListViewSeparated(AsyncSnapshot snapshot) { return ListView.separated( itemCount: snapshot.data.length, itemBuilder: (BuildContext context, int index) { String _titleDate = DateFormat.yMMMd().format(DateTime.parse(snapshot.data[index].date)); String _subtitle = snapshot.data[index].mood + "\n" + snapshot.data[index].note; return Dismissible(); }, separatorBuilder: (BuildContext context, int index) { return Divider( color: Colors.grey, ); }, ); }
  20. Finish the Dismissible() widget, which is responsible for deleting journal entries by swiping left or right on the entry itself. Set the key property to Key(snapshot.data[index].id), which creates a key from the journal entry id field.return Dismissible( key: Key(snapshot.data[index].id), );
  21. The background property is shown when the user swipes from left to right, and the secondaryBackground property is shown when the user swipes from right to left. For the background property, add a Container with the color set to Colors.red, the alignment set to Alignment.centerLeft, the padding set to EdgetInsets.only(left: 16.0), and the child property set to Icons.delete with the color property set to Colors.white.return Dismissible( key: Key(snapshot.data[index].id), background: Container( color: Colors.red, alignment: Alignment.centerLeft, padding: EdgeInsets.only(left: 16.0), child: Icon( Icons.delete, color: Colors.white, ), ), );
  22. For the secondaryBackground, use the same properties as the background but change the alignment property to Alignment.centerRight.“Screenshot of finishing the Home page by adding methods to load, add, modify, and save journal entries. Creating a method to build and use the ListView.separated constructor to customize your list of journal entries. In the ListView itemBuilder, adding a Dismissible to handle deleting journal entries.”return Dismissible( key: Key(snapshot.data[index].id), background: Container( color: Colors.red, alignment: Alignment.centerLeft, padding: EdgeInsets.only(left: 16.0), child: Icon( Icons.delete, color: Colors.white, ), ), secondaryBackground: Container( color: Colors.red, alignment: Alignment.centerRight, padding: EdgeInsets.only(right: 16.0), child: Icon( Icons.delete, color: Colors.white, ), ), child: ListTile(), onDismissed: (direction) { setState(() { _database.journal.removeAt(index); }); DatabaseFileRoutines().writeJournals(databaseToJson(_database)); }, );
  23. Finish the ListTile() widget, which is responsible for displaying each journal entry. You are going to customize the leading property to show the date’s day and weekday description. For the leading property, add a Column() with the children list of two Text widgets.child: ListTile( leading: Column( children: <Widget>[ Text(), Text(), ], ), ),“Finishing the ListTile() widget, which is responsible for displaying each journal entry. Customizing the leading property to depict the date's day and weekday description. For the leading property, adding a Column() with the children list of two Text widgets.”
  24. The first Text widget shows the day; let’s format it with the DateFormat.d() constructor using the format() with the snapshot.data[index].date. Since the data is in a String format, use the DateTime.parse() constructor to convert it to a date.Text(DateFormat.d().format(DateTime.parse(snapshot.data[index].date)), ),
  25. Set the style property to TextStyle with a fontWeight of FontWeight.boldfontSize of 32.0, and color set to Colors.blue.Text(DateFormat.d().format(DateTime.parse(snapshot.data[index].date)), style: TextStyle( fontWeight: FontWeight.bold, fontSize: 32.0, color: Colors.blue), ),
  26. The second Text widget shows the weekday; let’s format it with the DateFormat.E() constructor using the format() with the snapshot.data[index].date.Since the data is in a String format, use the DateTime.parse() constructor to convert it to a date.Text(DateFormat.E().format(DateTime.parse(snapshot.data[index].date))),
  27. Set the title property to a Text widget with the _titleDate variable and the style property to TextStyle with the fontWeight set to FontWeight.bold.title: Text( _titleDate, style: TextStyle(fontWeight: FontWeight.bold), ),
  28. Set the subtitle property to a Text widget with the _subtitle variable.subtitle: Text(_subtitle),
  29. Add the onTap property that calls the _addOrEditJournal() method and pass the add property as false, meaning not adding a new entry but modifying the current entry. Set the index property to index, which is the current entry index in the List.Set the journal property to the snapshot.data[index], which is the Journal class with the entry details containing the iddatemood, and note fields.onTap: () { _addOrEditJournal( add: false, index: index, journal: snapshot.data[index], ); },The following is the full ListTile widget:child: ListTile( leading: Column( children: <Widget>[ Text(DateFormat.d().format(DateTime.parse(snapshot.data[index].date)), style: TextStyle( fontWeight: FontWeight.bold, fontSize: 32.0, color: Colors.blue), ), Text(DateFormat.E().format(DateTime.parse(snapshot.data[index].date))), ], ), title: Text( _titleDate, style: TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text(_subtitle), onTap: () { _addOrEditJournal( add: false, index: index, journal: snapshot.data[index], ); }, ),The following is the full _buildListViewSeparated() method:// Build the ListView with Separator Widget _buildListViewSeparated(AsyncSnapshot snapshot) { return ListView.separated( itemCount: snapshot.data.length, itemBuilder: (BuildContext context, int index) { String _titleDate = DateFormat.yMMMd().format(DateTime.parse(snapshot.data[index].date)); String _subtitle = snapshot.data[index].mood + "\n" + snapshot.data[index].note; return Dismissible( key: Key(snapshot.data[index].id), background: Container( color: Colors.red, alignment: Alignment.centerLeft, padding: EdgeInsets.only(left: 16.0), child: Icon( Icons.delete, color: Colors.white, ), ), secondaryBackground: Container( color: Colors.red, alignment: Alignment.centerRight, padding: EdgeInsets.only(right: 16.0), child: Icon( Icons.delete, color: Colors.white, ), ), child: ListTile( leading: Column( children: <Widget>[ Text(DateFormat.d().format(DateTime.parse(snapshot.data[index].date)), style: TextStyle( fontWeight: FontWeight.bold, fontSize: 32.0, color: Colors.blue), ), Text(DateFormat.E().format(DateTime.parse(snapshot.data[index].date))), ], ), title: Text( _titleDate, style: TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text(_subtitle), onTap: () { _addOrEditJournal( add: false, index: index, journal: snapshot.data[index], ); }, ), onDismissed: (direction) { setState(() { _database.journal.removeAt(index); }); DatabaseFileRoutines().writeJournals(databaseToJson(_database)); }, ); }, separatorBuilder: (BuildContext context, int index) { return Divider( color: Colors.grey, ); }, ); }
“Adding the onTap property that calls the addOrEditJournal() method and passing the add property as false, meaning not adding a new entry but modifying the current entry. Setting the index property to index, which is the current entry index in the List.”

HOW IT WORKS

You completed the home.dart file that is responsible for showing a list of journal entries with the ability to add, modify, and delete individual records.

The FutureBuilder() calls the _loadJournals() method that retrieves journal entries, and while the data is loading, a CircularProgressIndicator() is displayed, and when the data is returned, the builder calls the _buildListViewSeparated(snapshot) method by passing the snapshot, which is the journal entries List.

The _loadJournals() method retrieves journal entries by calling the database classes to read the local database file, convert JSON objects to a List, sort the entries by DESC date, and return the List of journal entries.

The _addOrEditJournal() method handles adding new entries or modifying a journal entry. The constructor takes three named parameters to aid you if you are adding or modifying an entry. It uses the JournalEdit database class to track the action to take depending on whether the user pressed the Cancel or Save button. To show the edit entry page, you pass the constructor arguments by calling Navigator.push() and use the await keyword to receive the action taken from the edit entry page to the _journalEdit variable. The switch statement is used to evaluate the action taken to save the journal entry or cancel the changes.

The _buildListViewSeparated(snapshot) method uses the ListView.separated() constructor to build the list of journal entries. The itemBuilder returns a Dismissible() widget that handles deleting journal entries by swiping left or right on the entry. The Dismissible() child property uses ListTile() to format each journal entry in the ListView. The separatorBuilder returns the Divider() widget to show a grey divider line between journal entries.

SUMMARY

In this chapter, you learned how to persist data by saving and reading locally to the iOS and Android device filesystem. For the iOS device, you used the NSDocumentDirectory, and for the Android device, you used the AppData directory. The popular JSON file format was used to store the journal entries to a file. You created a journaling mood app that sorts the list of entries by DESC date and allows adding and modifying records.

You learned how to create the database classes for handling local persistence to encode and decode JSON objects and write and read entries to a file. You learned how to create the DatabaseFileRoutines class to obtain the path of the local device documents directory and save and read the database file using the File class. You learned how to create the Database class handling decoding and encoding JSON objects and converting them to a List of journal entries. You learned how to use json.encode to parse values to a JSON string and json.decode to parse the string to a JSON object. The Database class returns a List of Journal classes, List<Journal>. You learned how to create the Journal class to handle decoding and encoding the JSON objects for each journal entry. The Journal class contains the iddatemood, and note fields stored as String type. You learned how to create the JournalEdit class responsible for passing an action and individual Journal class entries between pages.

You learned how to create a journal entry page that handles both adding and modifying an existing journal entry. You learned how to use the JournalEdit class to receive a journal entry and return it to the Home page with an action and the modified entry. You learned how to call showDatePicker() to present a calendar to select a journal date. You learned to use the DateFormat class with different formatting constructors to display dates like ‘Sun, Jan 13, 2019‘. You learned how to use DateTime.parse() to convert a date saved as String to a DateTime instance. You learned how to use the TextField widget with the TextEditingController to access entry values. You learned how to customize the keyboard action button by setting the TextField TextInputAction. You learned how to move the focus between TextField widgets by using the FocusNode and the keyboard action button.

You learned how to create the Home page to show a list of journal entries sorted by DESC date separated by a Divider. You learned how to use the ListView.separated constructor to easily separate each journal entry with a separator. ListView.separated uses two builders, and you learned how to use the itemBuilder for showing the List of journal entries and the separatorBuilder to add a Divider widget between entries. You used the ListTile to format the List of journal entries easily. You learned how to customize the leading property with a Column to show the day and weekday on the leading side of the ListTile. You used the Dismissible widget to make it easy to delete journal entries by swiping left or right on the entry itself (ListTile). You used the Key('id') constructor to set a unique key for each Dismissible widget to make sure the correct journal entry is deleted.

In the next chapter, you’ll learn how to set up the Cloud Firestore backend NoSQL database. Cloud Firestore allows you to store, query, and synchronize data across devices without setting up your own servers.

image WHAT YOU LEARNED IN THIS CHAPTER

TOPICKEY CONCEPTS
Database classesThe four database classes collectively manage local persistence by writing, reading, encoding, and decoding JSON objects to and from the JSON file.
DatabaseFileRoutines classThis handles the File class to retrieve the device’s local document directory and save and read the data file.
Database classThis handles decoding and encoding the JSON objects and converting them to a List of journal entries.
Journal classThis handles decoding and encoding the JSON objects for each journal entry.
JournalEdit classThis handles the passing of individual journal entries between pages and the action taken.
showDatePickerThis presents a calendar to select a date.
DateFormatThis formats dates by using different formatting constructors.
DateTime.parse()This converts a String into a DateTime instance.
TextFieldThis allows text editing.
TextEditingControllerThis allows access to the value of the associated TextField.
TextInputActionThe TextField TextInputAction allows the customization of the keyboard action button.
FocusNodeThis moves the focus between TextField widgets.
ListView.separatedThis uses two builders, the itemBuilder and the separatorBuilder.
DismissibleSwipe to dismiss by dragging. Use the onDismissed to call custom actions such as deleting a record.
List().sortSort a List by using a Comparator.
NavigatorThis is used to navigate to another page. You can pass and receive data in a class by using the Navigator.
FutureYou can retrieve possible values available sometime in the future.
FutureBuilderThis works with a Future to retrieve the latest data without blocking the UI.
CircularProgressIndicatorThis is a spinning circular progress indicator showing that an action is running.
path_provider packageYou can access local iOS and Android filesystem locations.
intl packageYou can use DateFormat to format dates.
dart:io libraryYou can use the File class.
dart:convert libraryYou can decode and encode JSON objects.
dart:mathThis is used to call the Random() number generator.

Written by

XR Developer responsible for end-to-end development of XR solutions spanning multiple domains, by using various XR and WebXR libraries.

Leave a Reply