HTML5 Offline Mobile App Using Ionic and PouchDB

In this article I will demonstrate how to create an HTML5 mobile app using Ionic Framework which will be available even in offline mode. This app will show the RSS feeds from webspeaks.in and store them on your mobile device for offline access. So you will be able to view the feeds even when your internet connection is down.

I have chosen Ionic Framework for app development as I feel this is one of the most powerful and developer friendly framework with great command line support. For storing data we have a number of options available in terms of local storage, IndexedDB, WebSQL etc. But cross browser and cross platform implementation of these standards is a real pain. So for storage purposes I have used PouchDB database. PouchDB is a NoSQL database and is a JavaScript implementation of Apache CouchDB. Great thing about PouchDb is that it abstracts the underlying database implementation from us. It means that it can automatically chose which database engine to use behind the scenes. The greatest thing about PouchDB is that it has real time syncing capabilities i.e it can sync the local database on client with the remote database on server. However in this application we will not be using the syncing feature.
Download Script   Live Demo

In the demo you will see that the app loads the feed from API only first time. When you refresh the app, no ajax call is made to fetch the feeds but the feeds are loaded from local database. Now when you try pull to refresh, an ajax call is made to check if new feeds are available.
So lets start creating our app. You can see the complete code at GitHub repo.

I assume that you have node.js and npm installed on your system. You also need to install the Cordova, Ionic and PouchDB using npm, so run the following command in your terminal:

$ npm install -g cordova ionic pouchdb

We will create a blank Ionic project using command line as:

$ ionic start OfflineApp blank
$ cd OfflineApp
$ ionic platform add android
$ ionic platform add ios

Now we need to add PouchDB js file in our index.html as:

<script src="js/pouchdb-3.2.0.min.js"></script>

Note that we have used Bower for managing resources.

We have added ionic list template to our index.html like this:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
    <title></title>

    <link href="lib/ionic/css/ionic.css" rel="stylesheet">
    <link href="css/style.css" rel="stylesheet">

    <!-- IF using Sass (run gulp sass first), then uncomment below and remove the CSS includes above
    <link href="css/ionic.app.css" rel="stylesheet">
    -->

    <!-- lib/underscore js -->
    <script src="lib/underscore/underscore-min.js"></script>

    <!-- ionic/angularjs js -->
    <script src="lib/ionic/js/ionic.bundle.js"></script>

    <!-- lib/pouchdb js -->
    <script src="lib/pouchdb/dist/pouchdb.min.js"></script>

    <!-- cordova script (this will be a 404 during development) -->
    <script src="cordova.js"></script>

    <!-- your app's js -->
    <script src="js/app.js"></script>
  </head>

  <body ng-app="webspeaksApp">

    <ion-pane ng-controller="AppController">

        <ion-header-bar class="bar-calm">
            <h1 class="title">WebSpeaks.in</h1>
        </ion-header-bar>

        <ion-content>

            <ion-refresher pulling-text="Pull to Refresh..." on-refresh="refreshFeed()"></ion-refresher>

            <ion-list>
                <ion-item ng-repeat="feed in feeds" class="">
                    <a href="{{feed.link}}" ng-bind-html="feed.title" class="title"></a>
                    <p ng-bind-html="feed.contentSnippet" class="description"></p>
                    <div class="footer">
                        <span class="item-note">{{feed.publishedDate | limitTo:16}}</span>
                        <span class="author">-{{feed.author}}</span>
                    </div>
                </ion-item>
            </ion-list>

        </ion-content>

    </ion-pane>

  </body>
</html>

Now we will see the JS code for our app. I will explain the important parts in the code. You can see complete code from github repo. Open the www/js/app.js.
Initialze the PouchDB:

var localDB = new PouchDB("webspeaksdb");

In this app we are getting our blog feeds, so we have defined the URL of RSS feed as constant in our angular app:

// Define module constants
app.constant("config", {
    'FEED_URL': 'http://www.webspeaks.in/feed/atom',
    'PAGE_SIZE': 30
});

To load the feeds from remote URL, we have created an angular service like:

app.service("FeedService", function($http, $q) {

    // Return public API.
    return ({
        getFeed: getFeed
    });

    function getFeed(paramData) {
        paramData = paramData || {};
        var url = document.location.protocol + '//ajax.googleapis.com/ajax/services/feed/load?v=1.0&num='+ paramData.count +'&callback=JSON_CALLBACK&q=' + encodeURIComponent(paramData.url),
            request = $http.jsonp(url);

        return (request.then(handleSuccess, handleError));
    }

    // Transform the error response, unwrapping the application data from
    // the API response payload.
    function handleError(response) {
        // The API response from the server should be returned in a
        // nomralized format. However, if the request was not handled by the
        // server (or what not handles properly - ex. server error), then we
        // may have to normalize it on our end, as best we can.
        if (!angular.isObject(response.data) || !response.data.message) {
            return ($q.reject("An unknown error occurred."));
        }
        // Otherwise, use expected error message.
        return ($q.reject(response.data.message));
    }

    // I transform the successful response, unwrapping the application data
    // from the API response payload.
    function handleSuccess(response) {
        if (response.data && response.data.responseData && response.data.responseData.feed) {
            if (response.data.responseData.feed.entries) {
                if (response.data.responseData.feed.entries.length) {
                    return (response.data.responseData.feed.entries);
                }
            }
        }
    }
});

The code is self explanatory. It fetches the feeds from a URL and returns a promise object.

To fetch the feeds from local PouchDB database, we have created another angular service called DAO (Data Access Object) as:

app.service("DAO", function($q) {

    // Return public API.
    return ({
        getFeed: getFeed
    });

    function getFeed() {
        var deferred = $q.defer();
        localDB.allDocs({include_docs: true, descending: true}, function(err, doc) {
            if (err) {
                deferred.reject(err);
            } else {
                var rows = [];
                for (var x in doc.rows) {
                    rows.push(doc.rows[x].doc.post);
                }
                deferred.resolve(rows);
            }
        });
        return deferred.promise;
    }

});

Now we will go through our angular controller. In the controller we call the initFeed() function which in turn calls the getLocalFeed(). getLocalFeed() tries to find the feeds from local database. If no feeds are found in database, it will then call getRemoteFeed() which will fetch the feeds from feed service (created above).
We have created a watch on our $scope.feeds variable. So when there is a change in $scope.feeds we store the feeds in our local database:

// Watch the feeds property
// If new feed is found, add it to DB
$scope.$watch("feeds", function(newPosts, oldPosts) {
    if (newPosts.length) {
        _.each(newPosts, function(newPost) {

            // If the new post is not present in local DB
            // add it to the DB
            var exists = _.findWhere($scope.localFeeds, {link: newPost.link});
            if (_.isUndefined(exists)) {
                var feed = {
                    // We use the URL of post as document ID
                    _id: newPost.link,
                    post: newPost
                };

                // Add the new post to local DB
                localDB.post(feed, function callback(err, result) {
                    if (!err) {
                      console.log('Successfully posted a feed!');
                    }
                });
            }
        });
    }
});

So each time the controller is called, it first checks the local database for feeds. If none found, it calls the remote API for the feeds and stores them in database for offline use.
We have also implemented “Pull to Refresh” feature (thanks to Ionic for easy implementation). On refresh, we call the remote feeds API and update the local database with the new feeds.

Now you can see the complete app.js:

var app = angular.module('webspeaksApp', ['ionic', 'ngSanitize']);

// Create the PouchDB database instance
var localDB = new PouchDB("webspeaksdb");

app.run(function($ionicPlatform) {
    $ionicPlatform.ready(function() {
        // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard
        // for form inputs)
        if (window.cordova && window.cordova.plugins.Keyboard) {
            cordova.plugins.Keyboard.hideKeyboardAccessoryBar(true);
        }
        if (window.StatusBar) {
            StatusBar.styleDefault();
        }
    });
});

// Define module constants
app.constant("config", {
    'FEED_URL': 'http://www.webspeaks.in/feed/atom',
    'PAGE_SIZE': 30
});

app.controller("AppController", ['$scope', 'config', 'DAO', 'FeedService', '$ionicLoading', '$ionicPopup',
    function($scope, config, DAO, FeedService, $ionicLoading, $ionicPopup) {

    $scope.feeds = [];  // Feeds to be shown on UI
    $scope.localFeeds = []; // Feeds from local DB

    // Watch the feeds property
    // If new feed is found, add it to DB
    $scope.$watch("feeds", function(newPosts, oldPosts) {
        if (newPosts.length) {
            _.each(newPosts, function(newPost) {

                // If the new post is not present in local DB
                // add it to the DB
                var exists = _.findWhere($scope.localFeeds, {link: newPost.link});
                if (_.isUndefined(exists)) {
                    var feed = {
                        // We use the URL of post as document ID
                        _id: newPost.link,
                        post: newPost
                    };

                    // Add the new post to local DB
                    localDB.post(feed, function callback(err, result) {
                        if (!err) {
                          console.log('Successfully posted a feed!');
                        }
                    });
                }
            });
        }
    });

    /**
     * Get the feeds from local DB using DAO service
     */
    $scope.getLocalFeed = function() {
        var localFeed = DAO.getFeed();
        localFeed.then(function(response) {
            if (response && response.length) {
                $scope.feeds = response;
                $scope.localFeeds = response;

                // Hide the loader
                $ionicLoading.hide();
            } else {
                // If no feeds are found in local DB
                // call the feeds API
                $scope.getRemoteFeed();
            }
        }, function() {
            // In case of error, call feeds API
            $scope.getRemoteFeed();
        });
    };

    /**
     * Get the feeds from remote feeds API using FeedService
     */
    $scope.getRemoteFeed = function() {
        if (!$scope.isOnline()) {
            $ionicPopup.alert({
                title: 'Oops!',
                template: 'You seem to be offline?'
            }).then(function() {
                $ionicLoading.hide();
                $scope.$broadcast('scroll.refreshComplete');
            });
        } else {
            FeedService
                .getFeed({url: config.FEED_URL, count: config.PAGE_SIZE})
                .then(function(response) {
                    $scope.feeds = response;
                    $ionicLoading.hide();
                    $scope.$broadcast('scroll.refreshComplete');
                }, function() {
                }, function() {
                    $ionicLoading.hide();
                    $scope.$broadcast('scroll.refreshComplete');
                });
        }
    };

    /**
     * Called on application load and loads the feeds
     */
    $scope.initFeed = function() {
        $ionicLoading.show({
            template: 'Loading...'
        });
        $scope.getLocalFeed();
    };

    /**
     * Called on "pull to refresh" action
     */
    $scope.refreshFeed = function() {
        $scope.getRemoteFeed();
    };

    $scope.isOnline = function() {
        var networkState = null;

        if (navigator.connection) {
            networkState = navigator.connection.type;
        }

        if (networkState && networkState === Connection.NONE) {
            return false;
        }
        if (navigator.onLine) {
            return true;
        } else {
            return false;
        }
    };

    // Initialize the feeds
    $scope.initFeed();
}]);

/**
 * This service calls the remote feeds API
 * and returns the feeds response
 */
app.service("FeedService", function($http, $q) {

    // Return public API.
    return ({
        getFeed: getFeed
    });

    function getFeed(paramData) {
        paramData = paramData || {};
        var url = document.location.protocol + '//ajax.googleapis.com/ajax/services/feed/load?v=1.0&num='+ paramData.count +'&callback=JSON_CALLBACK&q=' + encodeURIComponent(paramData.url),
            request = $http.jsonp(url);

        return (request.then(handleSuccess, handleError));
    }

    // Transform the error response, unwrapping the application data from
    // the API response payload.
    function handleError(response) {
        // The API response from the server should be returned in a
        // nomralized format. However, if the request was not handled by the
        // server (or what not handles properly - ex. server error), then we
        // may have to normalize it on our end, as best we can.
        if (!angular.isObject(response.data) || !response.data.message) {
            return ($q.reject("An unknown error occurred."));
        }
        // Otherwise, use expected error message.
        return ($q.reject(response.data.message));
    }

    // I transform the successful response, unwrapping the application data
    // from the API response payload.
    function handleSuccess(response) {
        if (response.data && response.data.responseData && response.data.responseData.feed) {
            if (response.data.responseData.feed.entries) {
                if (response.data.responseData.feed.entries.length) {
                    return (response.data.responseData.feed.entries);
                }
            }
        }
    }
});

/**
 * The "Data Access Object" service
 * This service gets the feeds from local database
 * using PouchDB database object
 */
app.service("DAO", function($q) {

    // Return public API.
    return ({
        getFeed: getFeed
    });

    function getFeed() {
        var deferred = $q.defer();
        localDB.allDocs({include_docs: true, descending: true}, function(err, doc) {
            if (err) {
                deferred.reject(err);
            } else {
                var rows = [];
                for (var x in doc.rows) {
                    rows.push(doc.rows[x].doc.post);
                }
                deferred.resolve(rows);
            }
        });
        return deferred.promise;
    }

});

Now our app is ready to be built on all platforms.

Written by Arvind Bhardwaj

Arvind is a Magento and WordPress expert with more than 6 years of industry wide experience.

Website: http://www.webspeaks.in/

20 thoughts on “HTML5 Offline Mobile App Using Ionic and PouchDB

  1. Nice article! Just a quick tip – since PouchDB returns normal Promises, you can directly convert them into $q promises using $q.when. For instance:

    “`js
    function getFeed() {
    return $q.when(localDB.allDocs({include_docs: true, descending: true})).then(function (doc) {
    return doc.rows.map(function (x) {
    return x.doc.post;
    });
    });
    }
    “`

  2. I have tried to ran on fresh install/fresh git clone and also tried to follow article on clean install.
    On ionic serve I have following error:
    <pre>
    0 391081 error ReferenceError: Can't find variable: PouchDB, http://localhost:8100/js/app.js, Line: 4
    1 391133 error Error: [ng:areq] Argument 'AppController' is not a function, got undefined
    http://errors.angularjs.org/1.3.13/ng/areq?p0=AppController&p1=not%20a%20function%2C%20got%20undefined
    http://localhost:8100/lib/ionic/js/ionic.bundle.js:7982:32
    assertArg@http://localhost:8100/lib/ionic/js/ionic.bundle.js:9499:19
    assertArgFn@http://localhost:8100/lib/ionic/js/ionic.bundle.js:9509:12
    http://localhost:8100/lib/ionic/js/ionic.bundle.js:16350:20
    http://localhost:8100/lib/ionic/js/ionic.bundle.js:15518:45
    forEach@http://localhost:8100/lib/ionic/js/ionic.bundle.js:8250:24
    nodeLinkFn@http://localhost:8100/lib/ionic/js/ionic.bundle.js:15505:18
    compositeLinkFn@http://localhost:8100/lib/ionic/js/ionic.bundle.js:14997:23
    compositeLinkFn@http://localhost:8100/lib/ionic/js/ionic.bundle.js:15000:24
    publicLinkFn@http://localhost:8100/lib/ionic/js/ionic.bundle.js:14876:45
    http://localhost:8100/lib/ionic/js/ionic.bundle.js:9369:27
    $eval@http://localhost:8100/lib/ionic/js/ionic.bundle.js:22320:28
    $apply@http://localhost:8100/lib/ionic/js/ionic.bundle.js:22419:28
    bootstrapApply@http://localhost:8100/lib/ionic/js/ionic.bundle.js:9367:21
    invoke@http://localhost:8100/lib/ionic/js/ionic.bundle.js:12104:22
    doBootstrap@http://localhost:8100/lib/ionic/js/ionic.bundle.js:9365:20
    bootstrap@http://localhost:8100/lib/ionic/js/ionic.bundle.js:9385:23
    angularInit@http://localhost:8100/lib/ionic/js/ionic.bundle.js:9279:14
    http://localhost:8100/lib/ionic/js/ionic.bundle.js:34044:16
    trigger@http://localhost:8100/lib/ionic/js/ionic.bundle.js:10663:9
    eventHandler@http://localhost:8100/lib/ionic/js/ionic.bundle.js:10933:25
    </pre>
    What went wrong in my case?

    Regards,
    Alex

    1. Hi Alex,
      I am sure you have not included PouchDB JS file in index.html or you have not installed PouchDB using Bower.
      Please make sure PouchDB JS file is present at its expected location:
      <script src="lib/pouchdb/dist/pouchdb.min.js"></script>

  3. I have tried to implement this example in a system of tabs, but it throws the error "Unknown provider: configProvider <- config <- AppController".

    As would be implemented? (the tabs are the example of "ionic start myApp tabs")

  4. hi! i tested on desktop browser (google chrome) and it is working fine, load the feeds and able to show the offline popup box, unfortunately it's not working in android also ios (I use ionicView). any reason and how to solve this?

  5. Hi,
    I have tried this great pouchdb example, and it works just fine with "ionic serve" and "ripple emulate"…but when i build it and installed in my device (motog2 with android 5.02) the app freeze.

    Inspecting it with chrome, i have these errors:

    ReferenceError: Connection is not defined
    at Scope.$scope.isOnline (app.js:132)
    at Scope.$scope.getRemoteFeed (app.js:85)
    at app.js:73
    at processQueue (ionic.bundle.js:21108)
    at ionic.bundle.js:21124
    at Scope.$eval (ionic.bundle.js:22320)
    at Scope.$digest (ionic.bundle.js:22136)
    at ionic.bundle.js:22359
    at completeOutstandingRequest (ionic.bundle.js:12824)
    at ionic.bundle.js:13204(anonymous function) @ ionic.bundle.js:19526

    Note, i have installed the plugins:
    $ cordova plugin list
    com.ionic.keyboard 1.0.4 "Keyboard"
    cordova-plugin-console 1.0.1 "Console"
    cordova-plugin-device 1.0.1 "Device"
    cordova-plugin-splashscreen 2.1.0 "Splashscreen"

    …I'm forgetting something?

    regards
    MaX

      1. Hi,

        but I don't understand…

        Your code (downloadew from github have already the "navigator.connection.type;" method. Please see below.
        ——————————————-
        $scope.isOnline = function() {
        var networkState = null;
        if (navigator.connection) {
        networkState = navigator.connection.type;
        }
        if (networkState && networkState === Connection.NONE) {
        return false;
        }
        if (navigator.onLine) {
        return true;
        } else {
        return false;
        }
        };
        ——————————————–

        Do you think thar is a whitelist plugins problem?

        regards,
        MaX

        1. new test with this error now:

          file://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num=30&callback=angular.callbacks._0&q=http%3A%2F%2Fwww.webspeaks.in%2Ffeed%2Fatom Failed to load resource: net::ERR_FILE_NOT_FOUND

          I have there enty in the config.xml
          ————————————————-
          <plugin name="Whitelist" value="cordova-plugin-whitelist" />
          <plugin name="Device" value="org.apache.cordova.device" />
          <plugin name="Notification" value="org.apache.cordova.dialogs" />
          <plugin name="Network Information" value="org.apache.cordova.network-information" />
          <plugin name="Splashscreen" value="cordova-plugin-splashscreen" />
          <content src="index.html"/>
          <access origin="*"/>
          <allow-intent href="http://*/*&quot; />
          —————————–

          and there plugins installed:
          $ cordova plugin list
          com.ionic.keyboard 1.0.4 "Keyboard"
          cordova-plugin-console 1.0.1 "Console"
          cordova-plugin-device 1.0.1 "Device"
          cordova-plugin-splashscreen 2.1.0 "Splashscreen"
          org.apache.cordova.network-information 0.2.15 "Network Information"

  6. Hi!

    I solve it, finally!!! 😀

    In order to compile and run in android (hoping in ios too), you must install the following plugins:

    com.ionic.keyboard 1.0.4 "Keyboard"
    cordova-plugin-console 1.0.1 "Console"
    cordova-plugin-device 1.0.1 "Device"
    cordova-plugin-network-information 1.0.1 "Network Information"
    cordova-plugin-splashscreen 2.1.0 "Splashscreen"
    cordova-plugin-whitelist 1.0.0 "Whitelist"

    Add in the config.xml the following lines:

    <plugin name="Whitelist" value="cordova-plugin-whitelist" />
    <plugin name="Device" value="org.apache.cordova.device" />
    <plugin name="Notification" value="org.apache.cordova.dialogs" />
    <plugin name="Network Information" value="org.apache.cordova.network-information" />
    <plugin name="Splashscreen" value="cordova-plugin-splashscreen" />
    <content src="index.html"/>
    <access origin="*"/>
    <allow-intent href="http://*/*&quot; />
    <allow-navigation href="*" />

    1. Part 2

      Then add this line in the index.html after the <head> tag
      meta http-equiv="Content-Security-Policy" content="default-src *; style-src 'self' 'unsafe-inline'; script-src 'self' https://ajax.googleapis.com 'unsafe-inline' 'unsafe-eval'"

      …finally, edit www/js/app.js

      and put:
      var url = 'https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num='+ paramData.count +'&callback=JSON_CALLBACK&q=' + encodeURIComponent(paramData.url),

      instead of:
      var url = document.location.protocol + '//ajax.googleapis.com/ajax/services/feed/load?v=1.0&num='+ paramData.count +'&callback=JSON_CALLBACK&q=' + encodeURIComponent(paramData.url),

      …not exactly elegant, but it works

      regards,
      MaX

  7. Part 2

    Then add this line in the index.html after the <head> tag
    meta http-equiv="Content-Security-Policy" content="default-src *; style-src 'self' 'unsafe-inline'; script-src 'self' https://ajax.googleapis.com 'unsafe-inline' 'unsafe-eval'"

    …finally, edit www/js/app.js

    and put:
    var url = 'https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num='+ paramData.count +'&callback=JSON_CALLBACK&q=' + encodeURIComponent(paramData.url),

    instead of:
    var url = document.location.protocol + '//ajax.googleapis.com/ajax/services/feed/load?v=1.0&num='+ paramData.count +'&callback=JSON_CALLBACK&q=' + encodeURIComponent(paramData.url),

    …not exactly elegant, but it works

    regards,
    MaX

  8. Hey,
    I used your code to create an offline application, but I have a problem when it comes to updating changes on the database. Every time I get a new article from the feed service, it doesn't update my local database, and could also create a scenario whereby I can get the database working if I an article gets deleted from the api request

  9. This is awesome .. I got it working for my own blog!. This is working on phone.

    Do you got any blogs/tutorials for data synchronisation also?

    Is it mandatory to implement couchdb on server side?

    1. @SREEDHAR
      1. No tutorial right now for data synchronization, may be soon.
      2. CouchDB should be implemented if you want data synchronization on multiple devices or permanent data storage. I have used PouchDB, as it is only client side and does not need CouchDB server.

Leave a Reply

Your email address will not be published. Required fields are marked *

2 + eighteen =