Future
class with the showDatePicker
to present a calendar to choose datesFuture
class to save, read, and parse JSON filesListView.separated
constructor to section records with a Divider
List
().sort
to sort journal entries by datetextInputAction
to customize keyboard actionsFocusNode
and FocusScope
with the keyboard onSubmitted
to move the cursor to the next entry's TextField
Navigator
In this chapter, 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.
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
KEY | COLON | VALUE |
|
: |
|
|
: | 3 |
|
: | 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
TYPE | SAMPLE |
Object |
|
Array with values only |
|
Array with key/value |
|
Object with array |
|
Multiple objects with arrays |
|
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 id
, date
, mood
, 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."
},
],
}
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.
DatabaseFileRoutines
class uses the File
class to retrieve the device local document directory and save and read the data file.Database
class is responsible for encoding and decoding the JSON file and mapping it to a List
.Journal
class maps each journal entry from and to JSON.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);
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
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
COMPARE | TRUE | SAME | FALSE |
date2.compareTo(date1) | 1 | 0 | ‐1 |
2019‐01‐20 > 2019‐01‐22 | ‐1 | ||
2019‐01‐20 < 2019‐01‐22 | 1 | ||
2019‐01‐22 = 2019‐01‐22 | 0 |
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));
In mobile applications, it is important not to block the UI while retrieving or processing data. In Chapter 3, “Learning Dart Basics,” you learned how to use a Future
to retrieve a possible value that is available sometime in the future. A FutureBuilder
widget works with a Future
to retrieve the latest data without blocking the UI. The three main properties that you set are initialData
, future
, 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 active
, waiting
, done
, 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);
},
),
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 id
, date
, mood
, 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.
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.
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.
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.
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.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.Journal
class handles decoding and encoding the JSON objects for each journal entry. The Journal
class contains the id
, date
, mood
, and note
journal entry fields stored as String
s.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 id
, date
, mood
, and note
variables.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 add
, index
, 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
VARIABLE | DESCRIPTION AND VALUE |
final bool add |
If 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 index |
If 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 id , date , mood , 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 |
Cancel | The 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. |
Save | The JournalEdit action variable is set to 'Save' , and the journal variable is set with the current Journal class values, the id , date , mood , 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: context
, initialDate
, firstDate
, and lastDate
(Figure 13.3).
TABLE 13.6: showDatePicker
PROPERTY | VALUE |
context |
You pass the BuildContext as the context. |
initialDate |
You pass the journal date that is highlighted and selected in the calendar. |
firstDate |
The oldest date range available to be picked in the calendar from today's date. |
lastDate |
The newest date range available to be picked in the calendar from today's date. |
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.
In Chapter 6, “Using Common Widgets,” you learned how to use a Form
with the TextFormField
to create an entry form. 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 words
, sentences
, characters
, or none
(default).
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 Image
, Icon
, 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).
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
that you learned how to use in Chapters 3 and 12 (“Learning Dart Basics” and “Writing Platform‐Native Code”) 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 DatabaseFileRoutines
, Database
, Journal
, and JournalEdit
.
DatabaseFileRoutines
calls to read the JSON file located in the device documents folder.Database
class to parse the JSON to a List
format.List sort
function to sort entries by DESC date.List
is returned to the FutureBuilder
, and the ListView
displays existing journal entries.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 id
, date
, mood
, 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.
TOPIC | KEY CONCEPTS |
Database classes | The four database classes collectively manage local persistence by writing, reading, encoding, and decoding JSON objects to and from the JSON file. |
DatabaseFileRoutines class |
This handles the File class to retrieve the device's local document directory and save and read the data file. |
Database class |
This handles decoding and encoding the JSON objects and converting them to a List of journal entries. |
Journal class |
This handles decoding and encoding the JSON objects for each journal entry. |
JournalEdit class |
This handles the passing of individual journal entries between pages and the action taken. |
showDatePicker |
This presents a calendar to select a date. |
DateFormat |
This formats dates by using different formatting constructors. |
DateTime.parse() |
This converts a String into a DateTime instance. |
TextField |
This allows text editing. |
TextEditingController |
This allows access to the value of the associated TextField . |
TextInputAction |
The TextField TextInputAction allows the customization of the keyboard action button. |
FocusNode |
This moves the focus between TextField widgets. |
ListView.separated |
This uses two builders, the itemBuilder and the separatorBuilder . |
Dismissible |
Swipe to dismiss by dragging. Use the onDismissed to call custom actions such as deleting a record. |
List().sort |
Sort a List by using a Comparator . |
Navigator |
This is used to navigate to another page. You can pass and receive data in a class by using the Navigator . |
Future |
You can retrieve possible values available sometime in the future. |
FutureBuilder |
This works with a Future to retrieve the latest data without blocking the UI. |
CircularProgressIndicator |
This is a spinning circular progress indicator showing that an action is running. |
path_provider package |
You can access local iOS and Android filesystem locations. |
intl package |
You can use DateFormat to format dates. |
dart:io library |
You can use the File class. |
dart:convert library |
You can decode and encode JSON objects. |
dart:math |
This is used to call the Random() number generator. |