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
KEY | COLON | VALUE |
"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
TYPE | SAMPLE |
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 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."
},
],
}
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 theFile
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 aList
. - 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
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));
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 initialData
, future
, and builder
.
initialData
: Initial data to show before the snapshot is retrieved.Sample code:[]
future
: Calls aFuture
asynchronous method to retrieve data.Sample code:_loadJournals()
builder
: The builder property provides theBuildContext
andAsyncSnapshot
(data retrieved and connection state). TheAsyncSnapshot
returns a snapshot of the data, and you can also check for theConnectionState
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 thesnapshot.hasData
. To check the connection state, you use thesnapshot.connectionState
to see whether the state isactive
,waiting
,done
, ornone
. You can also check for errors by using thesnapshot.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 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.
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.

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.
- Create a new Flutter project and name it
ch13_local_persistence
. “Creating a Starter Project Template.” For this project, you need to create only thepages
andclasses
folders. - Open the
pubspec.yaml
file to add resources. In thedependencies:
section, add thepath_provider: ^1.1.0
andintl: ^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
- 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 ofProcess 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 typeflutter packages get
. - Open the
main.dart
file. Add to theThemeData
thebottomAppBarColor
property and set the color toColors.blue
.return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Local Persistence',
theme: ThemeData(
primarySwatch: Colors.blue,
bottomAppBarColor: Colors.blue,
),
home: Home(),
);
- Open the
home.dart
file and add to thebody
aFutureBuilder()
. TheFutureBuilder() initialData
property is an emptyList
created with the open and close square brackets ([]
). Thefuture
property calls the_loadJournals() Future
method that you create in the “Finishing the Journal Home Page” exercise. For thebuilder
property, you return aCircularProgressIndicator()
if thesnapshot.hasData
isfalse
, meaning no data has returned yet. Otherwise, you call the_buildListViewSeparated(snapshot)
method to build theListView
showing the journal entries.As you learned in the “Retrieving Data with the FutureBuilder” section, theAsyncSnapshot
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);
},
),
- After the
body
property, add thebottomNavigationBar
property and set it to aBottomAppBar()
. Set theshape
to aCircularNotchedRectangle()
and set thechild
to aPadding
of24.0
pixels. Add thefloatingActionButtonLocation
property and set it toFloatingActionButtonLocation.centerDocked
.bottomNavigationBar: BottomAppBar(
shape: CircularNotchedRectangle(),
child: Padding(padding: const EdgeInsets.all(24.0)),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
- Add the
floatingActionButton
property and set it to aFloatingActionButton()
. Normally theBottomAppBar()
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 theFloatingActionButton
to show a notch. TheFloatingActionButton
is responsible for adding new journal entries. Set theFloatingActionButton() child
property toIcon(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),
),
- Set the
FloatingActionButton() onPressed
property as anasync
callback that calls the_addOrEditJournal()
method. Add a call to the_addOrEditJournal()
method that takes three arguments:add
,index
, andjournal
. In “Finishing the Journal Home Page,” you’ll create the method that relies on thedatabase.dart
file creation.When the user taps this button, it’s to add a new entry, which is why you pass the argumentsadd
astrue
,index
as‐1
, andjournal
as a blankJournal
(class) entry. Because you also use the same method to edit an entry (user taps theListView
, covered in the final exercise), you would pass the argumentsadd
asfalse
,index
as the entryindex
from theListView
, andjournal
as theJournal
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());
},
),
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 theFile
class. TheFile
class is used by importing thedart:io
library, and to obtain the documents directory path, you import thepath_provider
package. - The
Database
class handles decoding and encoding the JSON objects and converting them to aList
of journal entries. You calldatabaseFromJson
to read and parse from JSON objects. You calldatabaseToJson
to save and parse to JSON objects. TheDatabase
class returns thejournal
variable consisting of aList
ofJournal
classes,List<Journal>
. Thedart:convert
library is used to decode and encode JSON objects. - The
Journal
class handles decoding and encoding the JSON objects for each journal entry. TheJournal
class contains theid
,date
,mood
, andnote
journal entry fields stored asString
s. - The
JournalEdit
class handles the passing of individual journal entries between pages. TheJournalEdit
class contains theaction
andjournal
variables. Theaction
variable is used to track whether the Save or Cancel button is pressed. Thejournal
variable contains the individual journal entry as aJournal
class containing theid
,date
,mood
, andnote
variables.
TRY IT OUT CREATING THE JOURNAL DATABASE CLASSES
In this section, you’ll create the DatabaseFileRoutines
, Database
, Journal
, and JournalEdit
classes. Note that the default constructors use curly brackets ({}
) to implement named parameters.
- Create a new Dart file under the
classes
folder. Right‐click theclasses
folder, select New ➪ Dart File, enterdatabase.dart
, and click the OK button to save. - Import the
path_provider.dart
package and thedart:io
anddart:convert
libraries. Add a new line and create theDatabaseFileRoutines
class.import 'package:path_provider/path_provider.dart'; // Filesystem locations
import 'dart:io'; // Used by File
import 'dart:convert'; // Used by json
class DatabaseFileRoutines {
}
- Inside the
DatabaseFileRoutines
class, add the_localPath async
method that returns aFuture<String>
, which is the documents directory path.Future<String> get _localPath async {
final directory = await getApplicationDocumentsDirectory();
return directory.path;
}
- Add the
_localFile async
method that returns aFuture<File>
with the reference to thelocal_persistence.json
file, which is thepath
, combined with the filename.Future<File> get _localFile async {
final path = await _localPath;
return File('$path/local_persistence.json');
}
- Add the
readJournals() async
method that returns aFuture<String>
containing the JSON objects. You’ll use atry‐catch
just in case there is an issue with reading the file.Future<String> readJournals() async {
try {
} catch (e) {
}
}
- Use
file.existsSync()
to check whether the file exists; if not, you create it by calling thewriteJournals('{"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 "";
}
}
- Add the
writeJournals(String json) async
method returning aFuture<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');
}
- Following the
DatabaseFileRoutines
class, create two methods that call theDatabase
class to handle the JSON decode and encode for the entire database. Create thedatabaseFromJson(String str)
method returning aDatabase
by passing it the JSON string. By usingjson.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);
}
- Create the
databaseToJson(Database data)
method returning aString
. By using thejson.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);
}
- Create the
Database
class, and the first item to declare is thejournal
variable of aList<Journal>
type, meaning it contains a list of journals. TheJournal
class contains each record, and you’ll create it in step 13. Declare theDatabase
constructor with the named parameterthis.journal
variable. Note you are using curly brackets ({}
) to declare the constructor named parameter.class Database {
List<Journal> journal;
Database({
this.journal,
});
}
- To retrieve and map the JSON objects to a
List<Journal>
(list ofJournal
classes), create thefactory Database.fromJson()
named constructor. Note that thefactory
constructor does not always create a new instance but might return an instance from a cache. The constructor takes the argument ofMap<String, dynamic>
, which maps theString
key with adynamic
value, the JSON key/value pair. The constructor returns theList<Journal>
by taking the JSON'journals'
key objects and mapping it from theJournal
class that parses the JSON string to theJournal
object containing each field such as theid
,date
,mood
, andnote
.factory Database.fromJson(Map<String, dynamic> json) => Database(
journal: List<Journal>.from(json["journals"].map((x) => Journal.fromJson(x))),
);
- To convert the
List<Journal>
to JSON objects, create thetoJson
method that parses eachJournal
class to JSON objects.Map<String, dynamic> toJson() => {
"journals": List<dynamic>.from(journal.map((x) => x.toJson())),
};
The following is the entireDatabase
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())),
};
}
- Create the
Journal
class and declare asString
types theid
,date
,mood
, andnote
variables. Declare theJournal
constructor with the named parametersthis.id
,this.date
,this.mood
, andthis.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,
});
}
- To retrieve and convert the JSON object to a
Journal
class, create thefactory Journal.fromJson()
named constructor. The constructor takes the argument ofMap<String, dynamic>
, which maps theString
key with adynamic
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"],
);
- To convert the
Journal
class to a JSON object, create thetoJson()
method that parses theJournal
class to a JSON object.Map<String, dynamic> toJson() => {
"id": id,
"date": date,
"mood": mood,
"note": note,
};
The following is the entireJournal
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,
};
}
- Create the
JournalEdit
class that is responsible for passing theaction
and ajournal
entry between pages. Add aString action
variable and aJournal journal
variable. Add the defaultJournalEdit
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 id
, date
, mood
, 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 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.
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).

TRY IT OUT CREATING THE JOURNAL ENTRY PAGE
In this section, you’ll create 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. The following graphic is the final journal entry page that you’ll create.

- Create a new Dart file under the
pages
folder. Right‐click thepages
folder, select New ➪ Dart File, enteredit_entry.dart
, and click the OK button to save. - Import the
material.dart
class, thedatabase.dart
class, theintl.dart
package, and thedart:math
library. Add a new line and create theEditEntry
class that extends aStatefulWidget
.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();
}
}
- After the class
EditEntry extends StatefulWidget {
and before the@override
, add the three variablesbool add
,int index
, andJournalEdit journalEdit
and mark them asfinal
.class EditEntry extends StatefulWidget {
final bool add;
final int index;
final JournalEdit journalEdit;
@override
_EditEntryState createState() => _EditEntryState();
}
- Add the
EditEntry
constructor withKey key
,this.add
,this.index
, andthis.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();
}
- Modify the
_EditEntryState
class and add the privateJournalEdit _journalEdit
,String _title
, andDateTime _selectedDate
variables. Note that the private_journalEdit
variable is populated from theJournalEdit
class value passed to theEditEntry
constructor.class _EditEntryState extends State<EditEntry> {
JournalEdit _journalEdit;
String _title;
DateTime _selectedDate;
@override
Widget build(BuildContext context) {
return Container();
}
}
- The mood and note use the
TextField
widget, which requires theTextEditingController
to access and modify the values. Add_moodController
and_noteController TextEditingController
variables and initialize them with theTextEditingController()
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();
}
}
- Declare the
_moodFocus
and_noteFocus FocusNode
variables and initialize them with theFocusNode()
constructor. You’ll use theFocusNode
with theTextInputAction
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();
}
}
- Override the
initState()
, and let’s initialize the variables with values passed to theEditEntry
constructor and make sure you add thesuper.initState()
.@override
void initState() {
super.initState();
}
- Initialize the
_journalEdit
variable by using theJournalEdit
class constructor by defaulting the action to'Cancel'
and the journal towidget.journalEdit.journal
value. Note that you use thewidget
to access the values from theEditEntry
constructor. Also, note that you access the individual journal entry from theJournalEdit
class by using the dot operator and then choosing thejournal
variable._journalEdit = JournalEdit(action: 'Cancel', journal: widget.journalEdit.journal);
- Initialize the
_title
variable by using a ternary operator to check whetherwidget.add
istrue
. If it is, set the value to'Add'
, and iffalse
, set the value to'Edit'
. By using the_title
variable, you customize theAppBar
‘stitle
to the action the user is taking. It’s the little details that make an app great._title = widget.add ? 'Add' : 'Edit';
- Initialize the
_journalEdit.journal
variable from thewidget.journalEdit.journal
variable._journalEdit.journal = widget.journalEdit.journal;
- To populate the entry fields on the page, add an
if‐else
statement. If thewidget.add
value istrue
, meaning adding a new journal record, then initialize the_selectedDate
variable with the current date by using theDateTime.now()
constructor and initialize the_moodController.text
and_noteController.text
to an empty string. If thewidget.add
value isfalse
, meaning editing a current journal record, then initialize the_selectedDate
variable with the_journalEdit.journal.date
and use theDateTime.parse
to convert the date fromString
to aDateTime
format. Also initialize the_moodController.text
with the_journalEdit.journal.mood
and the_noteController.text
with the_journalEdit.journal.note
. When you override theinitState()
method, make sure you start the method with a call tosuper.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;
}
}
- Override
dispose()
, and let’s dispose the twoTextEditingController
andFocusNode
; make sure you addsuper.dispose()
. When you override thedispose()
method, make sure you end the method with a call tosuper.dispose()
.@override
dispose() {
_moodController.dispose();
_noteController.dispose();
_moodFocus.dispose();
_noteFocus.dispose();
super.dispose();
}
- Add the
_selectDate(DateTime selectedDate) async
method that returns aFuture<DateTime>
. This method is responsible for calling the Flutter built‐inshowDatePicker()
that presents the user with a popup dialog displaying a Material Design calendar to choose dates.// Date Picker
Future<DateTime> _selectDate(DateTime selectedDate) async {
}
- Add the
DateTime _initialDate
variable and initialize it with theselectedDate
variable passed in the constructor.DateTime _initialDate = selectedDate;
- Add a final
DateTime _pickedDate
(the date the user picks from the calendar) variable and initialize it by calling theawait showDatePicker()
constructor. Pass thecontext
,initialDate
,firstDate
, andlastDate
arguments. Note that for thefirstDate
you use today’s date and subtract 365 days, and for thelastDate
, 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)),
);
- Add an
if
statement that checks that the_pickedDate
(the date the user picked from the calendar) variable does not equalnull
, meaning the user tapped the calendar’s Cancel button. If the user did pick a date, then modify theselectedDate
variable by using theDateTime()
constructor and pass the _pickedDate year
,month
, andday
. For the time, pass the_initialDate hour
,minute
,second
,millisecond
, andmicrosecond
. 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);
}
- Add a
return
statement to send back theselectedDate
.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;
}
- In the
Widget build()
method, replace theContainer()
with the UI widgetsScaffold
andAppBar
, and for thebody
property add aSafeArea()
andSingleChildScrollView()
with thechild
property as aColumn()
. Note that theAppBar title
uses theText
widget with the_title
variable to customize thetitle
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>[
],
),
),
),
);
}
- Add to the
Column children
aFlatButton
widget that is used to show the formatted selected date, and when the user taps the button, it presents the calendar. Set theFlatButton
padding property toEdgeInsets.all(0.0)
to remove padding for better aesthetics and add to thechild
property aRow()
widget.FlatButton(
padding: EdgeInsets.all(0.0),
child: Row(
children: <Widget>[
],
),
),
- Add to the
Row
children property theIcons.calendar_day Icon
with a size of22.0
and acolor
property set toColors.black54
.Icon(
Icons.calendar_today,
size: 22.0,
color: Colors.black54,
),
- Add a
SizedBox
with awidth
property set to16.0
to add a spacer.SizedBox(width: 16.0,),
- Add a
Text
widget and format the_selectedDate
with theDateFormat.yMMMEd()
constructor.Text(DateFormat.yMMMEd().format(_selectedDate),
style: TextStyle(
color: Colors.black54,
fontWeight: FontWeight.bold),
),
- Add the
Icons.arrow_drop_down Icon
with thecolor
property set toColors.black54
.Icon(
Icons.arrow_drop_down,
color: Colors.black54,
),
- Add the
onPressed()
callback and mark itasync
since calling the calendar is aFuture
event.onPressed: () async {
},
- Add to
onPressed()
theFocusScope.of().requestFocus()
method call to dismiss the keyboard if any of theTextField
widgets have focus. (This step is optional, but I wanted to show you how it’s accomplished.)FocusScope.of(context).requestFocus(FocusNode());
- Add a
DateTime _pickerDate
variable initialized by calling theawait _selectDate(_selectedDate) Future
method, which is why you add theawait
keyword. You added this method in step 14. - 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 fullFlatButton
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;
});
},
),
- Now it’s time to add the two
TextField
widgets for the mood and note fields. How do you set whichTextField
belongs to the mood or note? It’s thecontroller
, of course.For the moodTextField
, set the controller to_moodController
, and setautofocus
totrue
to automatically set the focus and show the keyboard when the page opens.TextField(
controller: _moodController,
autofocus: true,
),
- Set
textInputAction
toTextInputAction.next
telling the keyboard action button to move to the next field.textInputAction: TextInputAction.next,
- Set the
focusNode
to_moodFocus
andtextCapitalization
toTextCapitalization.words
, meaning every word is automatically capitalized.focusNode: _moodFocus,
textCapitalization: TextCapitalization.words,
- Set the
decoration
toInputDecoration
with thelabelText
set to'Mood'
and theicon
set toIcons.mood
.decoration: InputDecoration(
labelText: 'Mood',
icon: Icon(Icons.mood),
),
- For the
onSubmitted
property, enter the argument name assubmitted
and call theFocusScope.of(context).requestFocus(_noteFocus)
to have the keyboard action button change the focus to the noteTextField
. Note that I named the argumentsubmitted
, but it can be any name, likesubmittedValue
ormoodValue
.onSubmitted: (submitted) {
FocusScope.of(context).requestFocus(_noteFocus);
},
The following is the full moodTextField
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);
},
),
- For the note
TextField
, set the controller to_noteController
, and setautofocus
totrue
to automatically set the focus and show the keyboard when the page opens.TextField(
controller: _noteController,
),
- Set the
textInputAction
toTextInputAction.newline
telling the keyboard action button to insert a new line in theTextField
.textInputAction: TextInputAction.newline,
- Set the
focusNode
to_noteFocus
andtextCapitalization
toTextCapitalization.sentences
, meaning every first word of a sentence is automatically capitalized.focusNode: _noteFocus,
textCapitalization: TextCapitalization.sentences,
- Set the
decoration
toInputDecoration
with thelabelText
set to'Note'
and theicon
set toIcons.subject
.decoration: InputDecoration(
labelText: 'Note',
icon: Icon(Icons.subject),
),
- Set the
maxLines
property tonull
, allowing theTextField
to grow vertically to show the entire contents of the note. Using this technique is a great way to have aTextField
automatically grow to the size of the content without writing any code logic.maxLines: null,
The following is the full noteTextField
widget:TextField(
controller: _noteController,
textInputAction: TextInputAction.newline,
focusNode: _noteFocus,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: 'Note',
icon: Icon(Icons.subject),
),
maxLines: null,
),
- The last part for the entry page is to add Cancel and Save buttons. Add a
Row
and setmainAxisAlignment
toMainAxisAlignment.end
to align buttons to the right side of the page.Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
],
),
- Edit the
Row children
and add aFlatButton
with thechild
set to aText
widget to display'Cancel'
. Set thecolor
property toColors.grey.shade100
, making the button not the main action focus.FlatButton(
child: Text('Cancel'),
color: Colors.grey.shade100,
),
- For the
onPressed
property, modify the_journalEdit.action
to'Cancel'
and call theNavigator.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);
},
),
- Add a
SizedBox
with thewidth
set to8.0
to place a spacer between the two buttons.SizedBox(width: 8.0),
- Add the second
FlatButton
with thechild
set to aText
widget to display'Save'
. Set thecolor
property toColors.lightGreen.shade100
, making the button the main action focus.FlatButton(
child: Text('Save'),
color: Colors.lightGreen.shade100,
),
- For the
onPressed
property, modify the_journalEdit.action
to'Save'
.onPressed: () {
_journalEdit.action = 'Save';
},
- Since you are saving the entry, declare a
String _id
variable and use the ternary operator to check that thewidget.add
variable is set totrue
and use theRandom().nextInt(9999999)
to generate a random number. If thewidget.add
isfalse
, then use the current_journalEdit.journal.id
since you are editing an existing entry.Note thatnextInt()
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;
- Modify the
_journalEdit.journal
value by using theJournal()
class constructor and pass theid
property with the_id
variable, thedate
with the_selectedDate.toString()
(date is saved as aString
), themood
with the_moodController.text
, and thenote
with the_noteController.text
._journalEdit.journal = Journal(
id: _id,
date: _selectedDate.toString(),
mood: _moodController.text,
note: _noteController.text,
);
- 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 fullRow
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);
},
),
],
),
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 add
, index
, 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 id
, date
, mood
, and note
values.
The showDatePicker()
function displays a popup dialog that contains the Material Design date picker. You pass the context
, initialDate
, firstDate
, 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 words
, sentences
, 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 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
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
.
- You call the
DatabaseFileRoutines
calls to read the JSON file located in the device documents folder. - You call the
Database
class to parse the JSON to aList
format. - You use the
List sort
function to sort entries by DESC date. - The sorted
List
is returned to theFutureBuilder
, and theListView
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.
- Import the
edit_entry.dart
,database.dart
andintl.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
- After the class
_HomeState extends State<Home> {
, add theDatabase _database
variable. The_database
variable holds the parsed JSON objects of thejournal
JSON object, which is your list of journal entries.class _HomeState extends State<Home> {
Database _database;
- Add the
_loadJournals() async
method that returns aFuture<List<Journal>>
, which is aList
of theJournal
class entries.Future<List<Journal>> _loadJournals() async {
}
- Add the
await DatabaseFileRoutines().readJournals()
call and add with the dot notation a call tothen((journalsJson) {})
.What exactly is this call tothen()
? It registers a callback to be called when theFuture
completes. What this means is that once thereadJournals()
method completes and returns the value,then()
executes the code inside. Note that thejournalsJson
parameter receives the value from the JSON objects read from the savedlocal_persistence.json
file located in the device local documents folder.await DatabaseFileRoutines().readJournals().then((journalsJson) {
});
- Inside the
then()
callback, modify the_database
variable with the value from the call todatabaseFromJson(journalsJson)
.ThedatabaseFromJson
method in thedatabase.dart
class usesjson.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 DartList
, 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);
- 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));
- After the
then()
callback, add a new line with thereturn
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;
}
- Add the
_addOrEditJournal()
method that handles presenting the edit entry page to either add or modify a journal entry. You useNavigator.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 anasync
method taking the named parameters ofbool add
,int index
, andJournal journal
. Refer to Table 13.4 for the arguments description. Initiate theJournalEdit _journalEdit
variable with theJournalEdit
class with theaction
value set to an empty string and thejournal
value set to thejournal
variable that is passed in from the constructor.void _addOrEditJournal({bool add, int index, Journal journal}) async {
JournalEdit _journalEdit = JournalEdit(action: '', journal: journal);
}
- Add a new line; you are going to use the
Navigator
to pass the constructor values to the edit entry page by using theawait
keyword that passes the value back to the local_journalEdit
variable. For theMaterialPageRoute builder
, pass the constructor values to theEditEntry()
class and set thefullscreenDialog
property totrue
._journalEdit = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EditEntry(
add: add,
index: index,
journalEdit: _journalEdit,
),
fullscreenDialog: true
),
);
Once the edit entry page is dismissed, theswitch
statement executes next, and you’ll take appropriate action depending on the user’s selection. Theswitch
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 anif‐else
statement.switch (_journalEdit.action) {
}
- Add the first
switch case
statement that checks for the'Save'
value.If theadd
variable is set totrue
, meaning you are adding a new entry, then you use thesetState()
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;
}
- If the
add
variable is set tofalse
, meaning you are saving an existing entry, then you use thesetState()
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 theindex
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;
}
- To save the journal entry values to the device local storage documents directory, you call
DatabaseFileRoutines().writeJournals(databaseToJson(_database))
. Add the secondcase
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;
}
- 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;
}
}
- Add the
_buildListViewSeparated(AsyncSnapshot snapshot)
method that takes theAsyncSnapshot
parameter, which is theList
of journal entries. The method is called from theFutureBuilder()
in thebody
property. The way to read the journal entryList
is by accessing the snapshot data property by usingsnapshot.data
. Each journal entry is accessed by using theindex
likesnapshot.data[index]
. To access each field, you use thesnapshot.data[index].date
orsnapshot.data[index].mood
and so on.Widget _buildListViewSeparated(AsyncSnapshot snapshot) {
}
- The method returns a
ListView
by using theseparated()
constructor. Set theitemCount
property to thesnapshot.data.length
and set theitemBuilder
property to(BuildContext context, int index)
.Widget _buildListViewSeparated(AsyncSnapshot snapshot) {
return ListView.separated(
itemCount: snapshot.data.length,
itemBuilder: (BuildContext context, int index) {
},
);
}
- Inside the
itemBuilder
, initialize theString _titleDate
with theDateFormat.yMMMD()
constructor using theformat()
with thesnapshot.data[index].date
. Since the data is in aString
format, use theDateTime.parse()
constructor to convert it to a date.String _titleDate = DateFormat.yMMMd().format(DateTime.parse(snapshot.data[index].date));
- 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;
- Add the
return Dismissible()
, and you’ll complete it in step 20.return Dismissible();
- Add the
separatorBuilder
that handles the separator line between journal entries by using aDivider()
with thecolor
property set toColors.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,
);
},
);
}
- Finish the
Dismissible()
widget, which is responsible for deleting journal entries by swiping left or right on the entry itself. Set thekey
property toKey(snapshot.data[index].id)
, which creates akey
from the journal entryid
field.return Dismissible(
key: Key(snapshot.data[index].id),
);
- The
background
property is shown when the user swipes from left to right, and thesecondaryBackground
property is shown when the user swipes from right to left. For thebackground
property, add aContainer
with thecolor
set toColors.red
, thealignment
set toAlignment.centerLeft
, thepadding
set toEdgetInsets.only(left: 16.0)
, and thechild
property set toIcons.delete
with thecolor
property set toColors.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,
),
),
);
- For the
secondaryBackground
, use the same properties as thebackground
but change thealignment
property toAlignment.centerRight
.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));
},
);
- Finish the
ListTile()
widget, which is responsible for displaying each journal entry. You are going to customize theleading
property to show the date’s day and weekday description. For theleading
property, add aColumn()
with thechildren
list of twoText
widgets.child: ListTile(
leading: Column(
children: <Widget>[
Text(),
Text(),
],
),
),
- The first
Text
widget shows the day; let’s format it with theDateFormat.d()
constructor using theformat()
with thesnapshot.data[index].date
. Since the data is in aString
format, use theDateTime.parse()
constructor to convert it to a date.Text(DateFormat.d().format(DateTime.parse(snapshot.data[index].date)),
),
- Set the
style
property toTextStyle
with afontWeight
ofFontWeight.bold
,fontSize
of32.0
, andcolor
set toColors.blue
.Text(DateFormat.d().format(DateTime.parse(snapshot.data[index].date)),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 32.0,
color: Colors.blue),
),
- The second
Text
widget shows the weekday; let’s format it with theDateFormat.E()
constructor using theformat()
with thesnapshot.data[index].date
.Since the data is in aString
format, use theDateTime.parse()
constructor to convert it to a date.Text(DateFormat.E().format(DateTime.parse(snapshot.data[index].date))),
- Set the
title
property to aText
widget with the_titleDate
variable and thestyle
property toTextStyle
with thefontWeight
set toFontWeight.bold
.title: Text(
_titleDate,
style: TextStyle(fontWeight: FontWeight.bold),
),
- Set the
subtitle
property to aText
widget with the_subtitle
variable.subtitle: Text(_subtitle),
- Add the
onTap
property that calls the_addOrEditJournal()
method and pass theadd
property asfalse
, meaning not adding a new entry but modifying the current entry. Set theindex
property toindex
, which is the current entryindex
in theList
.Set thejournal
property to thesnapshot.data[index]
, which is theJournal
class with the entry details containing theid
,date
,mood
, andnote
fields.onTap: () {
_addOrEditJournal(
add: false,
index: index,
journal: snapshot.data[index],
);
},
The following is the fullListTile
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,
);
},
);
}

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 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.
WHAT YOU LEARNED IN THIS CHAPTER
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. |