Permanent Data I/O in Flutter: Adding “Starred” Comics

Up until now we have only used the temporary cache directory, but if we want to add a feature that allows the user to save some comics to local storage permanently as a way to keep their favorite comics always available to them. To do that, we need to have access to a permanent local storage facility.

That is available to us in the form of application documents directory, which is where an app is supposed to save data that should be kept until the user consciously deletes it.

We can get the path of the application documents directory using getApplicationDocumentsDirectory, and we can use it in the same way we use the cache directory we got through getTemporaryDirectory: we use getApplicationDocumentsDirectory().path as part of the path we give to the File’s constructor.

Let’s get to implementing this feature, starting with adding a star-shaped button in the app bar in the home page to access previously saved comics and one in the comic page to save comics.

But even before we do any of that, we need to turn the ComicPage into a StatefulWidget if we want the view to reflect the change in the comic’s state triggered by the user’s action. This means we need to do two things: change the definition of the ComicPage to be a subclass of StatefulWidget and turn what was up until now the ComicPage into a State<ComicPage> definition, inside which the same build method will reside:

 class​ ComicPage ​extends​ StatefulWidget {
  ComicPage(​this​.comic);
 
 final​ Map<​String​, ​dynamic​> comic;
 
  @override
  _ComicPageState createState() => _ComicPageState();
 }
 
 class​ _ComicPageState ​extends​ State<ComicPage> {
  @override
  Widget build(BuildContext context) {
 // return Scaffold(...)
  }
 }

The next step is to decide what kind of file we should use for storing the starred comics. We have used two different kind of files until now:

  • A JSON-encoded text file to store the comic data and a simple text file to store the latest comic number.

  • A binary file to save the image data.

The choices have been dictated by the nature of the data we needed to save: images are binary data, whereas we get the comics in a textual JSON format, meaning they can easily be saved in that format to local storage. A single number can be stored as binary data or text-based data without too many differences.

The starred comics will be a list of numbers, and there are advantages and disadvantages to each approach:

  • Saving in a binary format (using writeAsBytes) is simpler to implement and requires less computing overhead, but will generate files that are not readable by the user, but more efficient on storage.

  • Saving it in a JSON list makes it possible for the user to read and edit the file directly, but results in a less efficient use of computing power and storage space.

We are going to use binary format in this case since that seems to be the optimal solution for this problem. We’ll start by implementing an _addToStarred in the ComicPageState to add a comic to the starred comics list:

 void​ ​_addToStarred​(​int​ ​num​) {
 var​ file = File(​"​​$docsDir​​/starred"​);
  List<​int​> savedComics = json.decode(
  file.readAsStringSync()
  ).cast<​int​>();
 if​(isStarred) {
  savedComics.remove(​num​);
  }
 else​ {
  savedComics.add(​num​);
  }
  file.writeAsStringSync(json.encode(savedComics));
  }

which depends on the existence of isStarred as a ComicPage member variable:

 bool​ isStarred;

We need to determine whether or not the comic is starred when we load the ComicPage for the first time, since we will be changing that variable’s value whenever the user taps on the star button. Since we can’t have an async initState, we’ll use .then:

 void​ ​initState​() {
 super​.initState();
  getApplicationDocumentsDirectory().then(
  (dir) {
  docsDir = dir.path;
 var​ file = File(​"​​$docsDir​​/starred"​);
 if​(!file.existsSync()) {
  file.createSync();
  file.writeAsStringSync(​"[]"​);
  isStarred = ​false​;
  }
 else​ {
  setState((){isStarred = _isStarred(widget.comic[​"num"​]);});
  }
  }
  );
 }

This depends on an onStarred method that finds out if the comic have been starred by opening the file containing the starred comic numbers, casting it from a List of dynamic (which is a data type that can contain anything) to a List of integers and checking whether the comic number is contained in the list:

 bool​ ​_isStarred​(​int​ ​num​) {
 var​ file = File(​"​​$docsDir​​/starred"​);
  List<​int​> savedComics = json.decode(
  file.readAsStringSync()
  ).cast<​int​>();
 if​(savedComics.indexOf(​num​) != -1)
 return​ ​true​;
 else
 return​ ​false​;
 }

Adding the AppBar Action to the Comic Path

Now we need to implement a way to add each comic to the starred. The way we’ll do it in this case is by showing a star-shaped icon in the app bar that, when tapped, calls the _addToStarred method on the current comic, adding it to the starred comics list. To do that, we’ll add yet another AppBar action to our app in the ComicPage’s build method:

 actions: <Widget>[
  IconButton(
  icon: isStarred == ​true​ ?
  Icon(Icons.star) :
  Icon(Icons.star_border),
  tooltip: ​"Star Comic"​,
  onPressed: () {
  _addToStarred(widget.comic[​"num"​]);
  setState(() {
  isStarred = !isStarred;
  });
  }
  ),
 ],

This is what our new ComicPage looks like:

images/Networking/ComicPageNonFavorite.png

and what its app bar will look like if the current comic has been starred:

images/Networking/ComicPageFavorite.png

Implementing a Starred Comics List

We need a way for the user to find all of the comics they add to their favorite comics, and we’ll be doing that by creating a new widget: a new page in our app that will be just like the HomeScreen, but only showing starred comics.

Let’s define yet another widget and a fetchComic just like the one in the SelectionPage (one that fetches by comic number):

 class​ StarredPage ​extends​ StatelessWidget {
 
  StarredPage();
 
  Future<Map<​String​, ​dynamic​>> _fetchComic(​String​ n) async {
 final​ dir = await getTemporaryDirectory();
 var​ comicFile = File(​"​​${dir.path}​​/​​$n​​.json"​);
 
 if​(
  await comicFile.exists() &&
  comicFile.readAsStringSync() != ​""
  )
 return​ json.decode(comicFile.readAsStringSync());
 else​ {
  comicFile.createSync();
 final​ comic = json.decode(
  await http.read(​'https://xkcd.com/​​$n​​/info.0.json'​)
  );
 
  File(​'​​${dir.path}​​/​​$n​​.png'​)
  .writeAsBytesSync(await http.readBytes(comic[​"img"​]));
  comic[​"img"​] = ​'​​${dir.path}​​/​​$n​​.png'​;
  comicFile.writeAsString(json.encode(comic));
 
 return​ comic;
  }
  }
 }

While we’re at it, let’s also write a method to retrieve all of the saved comics and return a List containing all of the data, taking advantage of the _fetchComic method we just wrote:

 Future<List<Map<​String​, ​dynamic​>>> _retrieveSavedComics() async {
Directory docsDir = await getApplicationDocumentsDirectory();
  File file = File(​"​​${docsDir.path}​​/starred"​);
  List<Map<​String​, ​dynamic​>> comics = [];
 
if​(!file.existsSync()) {
  file.createSync();
  file.writeAsStringSync(​"[]"​);
  } ​else​ {
json.decode(file.readAsStringSync()).forEach(
  (n) async =>
  comics.add(await _fetchComic(n.toString()))
  );
  }
 return​ comics;
 }

Here we define the needed variables and fetch the application documents directory and use it to define the file which we wil be operating on.

We need to check whether the starred comics file exists; if it doesn’t, we just create the file and end up returning an empty list (we’ll work with that later when we build the UI for the starred comics page).

If the file exists, we fetch all of the comics listed in the file and return them.

Now we’ll build the UI for the StarredPage:

 @override
 Widget ​build​(BuildContext context) {
var​ comics = _retrieveSavedComics();
 
 return​ Scaffold(
  appBar: AppBar(
  title: Text(​"Browse your Favorite Comics"​)
  ),
  body: FutureBuilder(
  future: comics,
  builder: (context, snapshot) =>
snapshot.hasData && snapshot.data.isNotEmpty ?
  ListView.builder(
  itemCount: snapshot.data.length,
  itemBuilder: (context, i) =>
  ComicTile(comic: snapshot.data[i],),
  )
:
  Column(
  children: [
  Icon(Icons.not_interested),
  Text(​"""
  You haven't starred any comics yet.
  Check back after you have found something worthy of being here.
  """​),
  ]
  )
  )
 
  );
 }

The build method will be called just once, so adding this call anywhere wouldn’t change anything, but this is how you’re supposed to do it: put the call returning a Future anywhere it will be called just once and then use the value returned by that call as the FutureBuilder constructor’s future argument.

We need to check whether there are any starred comics. If there are any, we’ll show a ListView of ComicTiles as we do in the home page.

If there are no starred comics, we’ll just show a notice to the user. We are using multi-line strings as explained in the Dart appendix to this book (Characters and Strings).

The entire StarredPage code we’ve seen is combined into the following class definition:

 class​ StarredPage ​extends​ StatelessWidget {
 
  StarredPage();
 
  Future<Map<​String​, ​dynamic​>> _fetchComic(​String​ n) async {
 final​ dir = await getTemporaryDirectory();
 var​ comicFile = File(​"​​${dir.path}​​/​​$n​​.json"​);
 
 if​(
  await comicFile.exists() &&
  comicFile.readAsStringSync() != ​""
  )
 return​ json.decode(comicFile.readAsStringSync());
 else​ {
  comicFile.createSync();
 final​ comic = json.decode(
  await http.read(​'https://xkcd.com/​​$n​​/info.0.json'​)
  );
  File(​'​​${dir.path}​​/​​$n​​.png'​)
  .writeAsBytesSync(await http.readBytes(comic[​"img"​]));
  comic[​"img"​] = ​'​​${dir.path}​​/​​$n​​.png'​;
  comicFile.writeAsString(json.encode(comic));
 
 return​ comic;
  }
 }
 
 Future<List<Map<​String​, ​dynamic​>>> _retrieveSavedComics() async {
  Directory docsDir = await getApplicationDocumentsDirectory();
  File file = File(​"​​${docsDir.path}​​/starred"​);
  List<Map<​String​, ​dynamic​>> comics = [];
 
 if​(!file.existsSync()) {
  file.createSync();
  file.writeAsStringSync(​"[]"​);
  } ​else​ {
  json.decode(file.readAsStringSync()).forEach(
  (n) async =>
  comics.add(await _fetchComic(n.toString()))
  );
  }
 return​ comics;
 }
 
 @override
 Widget ​build​(BuildContext context) {
 var​ comics = _retrieveSavedComics();
 
 return​ Scaffold(
  appBar: AppBar(
  title: Text(​"Browse your Favorite Comics"​)
  ),
  body:
  FutureBuilder(
  future: comics,
  builder: (context, snapshot) =>
  snapshot.hasData && snapshot.data.isNotEmpty ?
  ListView.builder(
  itemCount: snapshot.data.length,
  itemBuilder: (context, i) =>
  ComicTile(comic: snapshot.data[i],),
  )
  :
  Column(
  children: [
  Icon(Icons.not_interested),
  Text(​"""
  You haven't starred any comics yet.
  Check back after you have found something worthy of being here.
  """​),
  ]
  )
  )
 
  );
  }
 
 }

This is what our starred comics’ list will look like:

images/Networking/FavoritePage.png

Adding a Link to the HomeScreen’s App Bar

Now we need the user to be able to reach this page from the rest of the app. We’ll make this possible by adding an AppBar action to the HomeScreen. This will be very similar to previous app bar actions we added earlier: when the user taps on the icon we show the user the StarredPage on top of the current view:

 IconButton(
  icon: Icon(Icons.star),
  tooltip: ​"Browse Starred Comics"​,
  onPressed: () {
  Navigator.push(
  context,
  MaterialPageRoute(
  builder: (BuildContext context) =>
  StarredPage()
  ),
  );
  }
 ),

This is what the HomeScreen’s app bar will look like:

images/Networking/HomeScreenFavorite.png
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset