Cache locally on Flutter with Localstorage


Introduction

Developing applications nearly always have to deal with data at some point and the data can come from user input or external source and the next thing the developer must handle is how to store the data.

After data is fetched from an external source like API, usually data is stored or cached locally so in the next call, we don’t need to call the API again to save resource and connection bandwidth also to have good performance.

In Flutter, we have 3 common options to cache locally:

  • Shared preferences
    Shared preferences is good to store simple value or small amount of data
  • Local database
    We can use SQLite in Android or CoreData in iOS but this option is good to store large amount of data but it is more complicated
  • Text file
    Text file is a good option if we need to store more complex data like some Dart objects but it is still not too large so we don’t need to use a local DB.

Localstorage

In this article we will explain how to cache data locally using the third option but with an additional helper library called localstorage that handles the process of writing and reading the file.

On the https://pub.dev/packages/localstorage, localstorage is described as “Simple json file-based storage for flutter” so we still need to deal with transforming the Dart object to json and back.

Setup

Start a new Flutter project with Android Studio or Visual Studio code

Dependency

To start, we should add the localstorage pub dependency on pubspec.yaml and for this article will use 3.0.1+4 version. We also need to add http package dependency because we will fetch data from external API.

pubspec.yaml

dependencies:

  flutter:

    sdk: flutter

  localstorage: ^3.0.1+4

  http: ^0.12.0+4

Make sure to run flutter packages get in Android Studio or flutter pub get to install the dependencies.

Post model

So after we create the flutter project called “cache_with_localstorage” in Android Studio, we need to create domain model to store the data. We will use the JSONPlaceholder which is fake online REST API that will provide the data.

Our application will get data from this URL:
https://jsonplaceholder.typicode.com/posts/1

So let’s start with the PostItem model that saved as model/post.dart

class PostItem {
int id;
String title;
String body;
bool fromCache = false;

PostItem({this.id, this.title, this.body, this.fromCache});

Map<String, dynamic> toJson() => {'id': id, 'title': title, 'body': body};

factory PostItem.fromJson(Map<String, dynamic> json) {
return PostItem(id: json['id'], title: json['title'], body: json['body']);
}
}

In the PostItem model, I added fromCache property to indicate whether the post is fetched from API or from local cache.

On that post.dart above, we have two important functions that will be used to convert object to json and back. Those functions must exist because they will be required to transform json to object when reading from localstorage and transform object to json before cache it to localstorage.

Post Service

After we have the model class, we need to write a PostService class that handles the API call to get the post, cache the post to localstorage and get the post back from localstorage.

In this class we will have some important code that we want to explain .

First we must import the localstorage package:

import 'package:localstorage/localstorage.dart';

And declare the variable for LocalStorage class like below:

static LocalStorage storage = new LocalStorage('post');

To use LocalStorage we use string as key as identifier of the underlying file, so if we want to store another data we should use a different key value.

It only needs one line of code to save object to localstorage:

storage.setItem("post", post);

Here is the function to save PostItem object to localstorage:

void savePost(PostItem post) async {
await storage.ready;
storage.setItem("post", post);
}

It also easy to get data from localstorage which requires only one line of code like below:

Map<String, dynamic> data = storage.getItem('post');

And below is function to get PostItem from localstorage:

Future<PostItem> getPostFromCache() async {
await storage.ready;
Map<String, dynamic> data = storage.getItem('post');
if (data == null) {
return null;
}
PostItem post = PostItem.fromJson(data);
post.fromCache = true; //to indicate post is pulled from cache
return post;
}

Before we call the storage.setItem and storage.getItem we need to make sure the storage is ready by calling storage.ready that returns Future and put await before it.

Then the function to get PostItem from the API can be seen below:


Future<PostItem> getPostFromAPI() async {
PostItem post = await fetchPost();
post.fromCache = false; //to indicate post is pulled from API
savePost(post);
return post;
}

Here is source code of service/post.dart

import 'dart:convert';

import 'package:cache_with_localstorage/model/post.dart';
import 'package:http/http.dart' as http;
import 'package:localstorage/localstorage.dart';

class PostService {
String baseURL = 'https://jsonplaceholder.typicode.com';
static LocalStorage storage = new LocalStorage('post');
var stopwatch = new Stopwatch()..start();

Future<PostItem> getPost() async {
var post = await getPostFromCache();
if (post == null) {
return getPostFromAPI();
}
return post;
}

Future<PostItem> getPostFromAPI() async {
PostItem post = await fetchPost();
post.fromCache = false; //to indicate post is pulled from API
savePost(post);
return post;
}

Future<PostItem> getPostFromCache() async {
await storage.ready;
Map<String, dynamic> data = storage.getItem('post');
if (data == null) {
return null;
}
PostItem post = PostItem.fromJson(data);
post.fromCache = true; //to indicate post is pulled from cache
return post;
}

void savePost(PostItem post) async {
await storage.ready;
storage.setItem("post", post);
}

Future<PostItem> fetchPost() async {
String _endpoint = '/posts/1';

dynamic post = await _get(_endpoint);
if (post == null) {
return null;
}
PostItem p = new PostItem.fromJson(post);
return p;
}

Future _get(String url) async {
String endpoint = '$baseURL$url';
try {
final response = await http.get(endpoint);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to load data');
}
} catch (err) {
throw Exception(err);
}
}
}

Home Screen

Flutter application that we want to build is very simple, it will have two screens where the first screen will try to load data from API and the next screen will get the data from cache.

The first screen will show the title from the Post and below it there is text “From cache: false” that indicates it pulls data from API, and below it there is a button that will show the next second page if it is clicked.

Inside initState, we call the function to PostService.getPost() to initialize the post variable that will be used in the SecondPage to show post title and fromCache status.

The first screen will look like below in Android emulator:

And here is full source code for FirstPage widget ( screen/first.dart ):

import 'package:cache_with_localstorage/screen/second.dart';
import 'package:cache_with_localstorage/widget/notifier.dart';
import 'package:flutter/material.dart';
import 'package:cache_with_localstorage/model/post.dart';
import 'package:cache_with_localstorage/service/post.dart';

class FirstPage extends StatefulWidget {
FirstPage({this.title});

final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();

final String title;

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

class _FirstPageState extends State<FirstPage> {
PostItem post;
PostService service = PostService();
Exception e;

void _loadPost() async {
try {
PostItem thePost = await service.getPost();
setState(() {
post = thePost;
});
} catch (err) {
setState(() {
e = err;
});
}
}

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

_loadPost();
}

@override
Widget build(BuildContext context) {
if (e != null) {
Future.delayed(
Duration.zero,
() => showAlert(
context, e.toString(), widget._scaffoldKey.currentState));
}

return Scaffold(
key: widget._scaffoldKey,
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: post == null
? showIndicator(e)
: Padding(
padding: EdgeInsets.all(100),
child: Column(children: <Widget>[
Text(post.title),
SizedBox(height: 20),
Text('From CACHE: ${post.fromCache}'),
SizedBox(height: 20),
FlatButton(
color: Colors.blueAccent,
textColor: Colors.white,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
SecondPage(title: "Load from cache")),
),
child: Text('Next Page'))
]))));
}
}

Second Screen

In second screen, inside initState(), we still do the same thing as FirstScreen, that call PostService getPost() to initialize post variable that will be used in the SecondPage, but this time getPost() will load from cache because we already cached the post using localstorage.

The source code for SecondPage widget (screen/second.dart):

import 'package:cache_with_localstorage/widget/notifier.dart';
import 'package:flutter/material.dart';
import 'package:cache_with_localstorage/model/post.dart';
import 'package:cache_with_localstorage/service/post.dart';

class SecondPage extends StatefulWidget {
SecondPage({this.title});

final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();

final String title;

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

class _SecondPageState extends State<SecondPage> {
PostItem post;
PostService service = PostService();
Exception e;

void _loadPost() async {
try {
PostItem thePost = await service.getPost();
setState(() {
post = thePost;
});
} catch (err) {
setState(() {
e = err;
});
}
}

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

@override
Widget build(BuildContext context) {
if (e != null) {
Future.delayed(
Duration.zero,
() => showAlert(
context, e.toString(), widget._scaffoldKey.currentState));
}

return Scaffold(
appBar: AppBar(
leading: new IconButton(
icon: new Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(widget.title),
),
body: Center(
child: post == null
? CircularProgressIndicator()
: Padding(
padding: EdgeInsets.all(100),
child: Column(children: <Widget>[
Text(post.title),
SizedBox(height: 20),
Text('From CACHE: ${post.fromCache}')
]))),
);
}
}

Main.dart

import 'package:cache_with_localstorage/screen/first.dart';
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Cache with LocalStorage',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: FirstPage(title: 'First Page'),
    );
  }
}

Then on main.dart we need to set the FirstPage widget as a home screen widget

Conclusion

On this article, we already learned that if we have requirement to store Dart object that is not too complex and when shared preference is not enough, we can choose to store the data to text file utilising package Localstorage that is easy to implement and can be used for many purpose, like to cache or store User Preference, Shopping Cart, Application Configuration, etc.

Git Repo

https://github.com/qreasio/cache_with_localstorage