When one of your project requirements is to have an offline mode where users can still use the app and information even without a connection, there are different ways to go about this.
In this article we will build an Ionic 4 app with offline mode that caches API data so it can be used as a fallback later. Also, we create an offline manager which stores requests made during that time so we can later send out the calls one by one when we are online again.
While there are caching plugins (that don’t work well with v4 yet) and PWA with service workers you sometimes have specific requirements and therefore this post aims to show how to build your own offline mode system – not 100% like described here perhaps but it will give you a good start in the right direction.
This post was created with the Ionic 4 Beta 13!
Starting our Offline Caching App
Let’s start the fun by creating a new Ionic 4 app, generating a bunch of services that we will need for the different features and finally the Ionic native and Cordova plugins for checking the network and using Ionic Storage:
ionic start offlineMode blank --type=angular cd offlineMode ionic g service services/api ionic g service services/network ionic g service services/offlineManager npm install @ionic/storage @ionic-native/network@beta ionic cordova plugin add cordova-sqlite-storage ionic cordova plugin add cordova-plugin-network-information
To make use of all the great things we need to import them inside our app/app.module.ts like always:
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule, RouteReuseStrategy, Routes } from '@angular/router'; import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { SplashScreen } from '@ionic-native/splash-screen/ngx'; import { StatusBar } from '@ionic-native/status-bar/ngx'; import { AppComponent } from './app.component'; import { AppRoutingModule } from './app-routing.module'; import { IonicStorageModule } from '@ionic/storage'; import { HttpClientModule } from '@angular/common/http'; import { Network } from '@ionic-native/network/ngx'; @NgModule({ declarations: [AppComponent], entryComponents: [], imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, IonicStorageModule.forRoot(), HttpClientModule], providers: [ StatusBar, SplashScreen, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, Network ], bootstrap: [AppComponent] }) export class AppModule { }
To test our API calls we will simply use the service reqres. It’s a simple mocking tool, and the data we send to the API won’t be used there but we’ll still get a response so it’s the perfect way to test our offline features!
Handling Network Changes
First of all we need to know whether we are currently connected to a network or not. For this we’ll use a simple service that informs us about all the changes happening to the network by subscribing to the onConnect()
/ onDisconnect()
events and then emitting a new value.
At this point you can also show a toast like we do or use any other mechanism to inform the user that the connection has changed.
The base for this service comes actually from one old Ionic Forum thread and I just made some additions to it!
This code is not specific to offline mode apps but could be used in general for all Ionic apps where connectivity is important, so go ahead and change your app/services/network.service.ts to:
import { Injectable } from '@angular/core'; import { Network } from '@ionic-native/network/ngx' import { BehaviorSubject, Observable } from 'rxjs'; import { ToastController, Platform } from '@ionic/angular'; export enum ConnectionStatus { Online, Offline } @Injectable({ providedIn: 'root' }) export class NetworkService { private status: BehaviorSubject<ConnectionStatus> = new BehaviorSubject(ConnectionStatus.Offline); constructor(private network: Network, private toastController: ToastController, private plt: Platform) { this.plt.ready().then(() => { this.initializeNetworkEvents(); let status = this.network.type !== 'none' ? ConnectionStatus.Online : ConnectionStatus.Offline; this.status.next(status); }); } public initializeNetworkEvents() { this.network.onDisconnect().subscribe(() => { if (this.status.getValue() === ConnectionStatus.Online) { console.log('WE ARE OFFLINE'); this.updateNetworkStatus(ConnectionStatus.Offline); } }); this.network.onConnect().subscribe(() => { if (this.status.getValue() === ConnectionStatus.Offline) { console.log('WE ARE ONLINE'); this.updateNetworkStatus(ConnectionStatus.Online); } }); } private async updateNetworkStatus(status: ConnectionStatus) { this.status.next(status); let connection = status == ConnectionStatus.Offline ? 'Offline' : 'Online'; let toast = this.toastController.create({ message: `You are now ${connection}`, duration: 3000, position: 'bottom' }); toast.then(toast => toast.present()); } public onNetworkChange(): Observable<ConnectionStatus> { return this.status.asObservable(); } public getCurrentNetworkStatus(): ConnectionStatus { return this.status.getValue(); } }
Storing API Requests Locally
Now we can see if we are online right now or not, and we will use this information to store important requests made during the time when we are offline.
In most cases there are calls that you don’t really need to send out again when the user is back online like any GET request. But if the user wants to perform other CRUD like operations you might want to keep track of that.
The difficulty here is of course that you would not only store the request but also perform the operation locally already upfront which might or might not work for your. We will skip this part as we don’t really have any logic in our app but it’s something you should keep in mind.
Our next service can store a request (which will be called from another service later) as an object with some information that you decide right to our Ionic storage. This means, while we are offline an array of API calls is created that we need to work off later.
This task is done by the checkForEvents()
function that will later be called whenever the user is back online. I tried to keep this service as general as possible you can stick it into your own projects!
Within our check function we basically get the array from the storage and send out all the requests one by one and finally delete the stored requests again. The function will return a result once every operation is finished so if you need to wait for the historic data to be sent, you can subscribe to the check and be sure that everything is finished when you continue!
Now let’s add the magic to your app/services/offline-manager.service.ts:
import { Injectable } from '@angular/core'; import { Storage } from '@ionic/storage'; import { from, of, forkJoin } from 'rxjs'; import { switchMap, finalize } from 'rxjs/operators'; import { HttpClient } from '@angular/common/http'; import { ToastController } from '@ionic/angular'; const STORAGE_REQ_KEY = 'storedreq'; interface StoredRequest { url: string, type: string, data: any, time: number, id: string } @Injectable({ providedIn: 'root' }) export class OfflineManagerService { constructor(private storage: Storage, private http: HttpClient, private toastController: ToastController) { } checkForEvents() { return from(this.storage.get(STORAGE_REQ_KEY)).pipe( switchMap(storedOperations => { let storedObj = JSON.parse(storedOperations); if (storedObj && storedObj.length > 0) { return this.sendRequests(storedObj).pipe( finalize(() => { let toast = this.toastController.create({ message: `Local data succesfully synced to API!`, duration: 3000, position: 'bottom' }); toast.then(toast => toast.present()); this.storage.remove(STORAGE_REQ_KEY); }) ); } else { console.log('no local events to sync'); return of(false); } }) ) } storeRequest(url, type, data) { let toast = this.toastController.create({ message: `Your data is stored locally because you seem to be offline.`, duration: 3000, position: 'bottom' }); toast.then(toast => toast.present()); let action: StoredRequest = { url: url, type: type, data: data, time: new Date().getTime(), id: Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5) }; // https://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript return this.storage.get(STORAGE_REQ_KEY).then(storedOperations => { let storedObj = JSON.parse(storedOperations); if (storedObj) { storedObj.push(action); } else { storedObj = [action]; } // Save old & new local transactions back to Storage return this.storage.set(STORAGE_REQ_KEY, JSON.stringify(storedObj)); }); } sendRequests(operations: StoredRequest[]) { let obs = []; for (let op of operations) { console.log('Make one request: ', op); let oneObs = this.http.request(op.type, op.url, op.data); obs.push(oneObs); } // Send out all local events and return once they are finished return forkJoin(obs); } }
Right now the app does not really make any sense so let’s move into the right direction now.
Making API Requests with Local Caching
The goal was to make API calls, return locally cached data when we are offline and also store some requests (that you identify as important) locally with our offline manager and requests queue until we are back online.
So far so good.
There’s also a great caching plugin I described within the Ionic Academy but it’s not yet working for v4 and also with the approach of this article you can build the system just like your app needs it so you control everything.
Anyway, we continue with the class that actually performs the API calls and you’ll most likely already have something like this.
At this point you can now make use of both of our previous services.
First of all we can check the internet connection inside our functions to see if we are maybe offline in which case we either directly return the cached result from our storage or otherwise store the PUT request within our offline manager so it can be performed later on.
Along this and the other classes you have seen quite a few RxJS operators, and if they scare you I can understand you 100%. I’ve slowly made my way through different operators and how to use them, and I’m far from an expert.
They basically help us to transform our Observable data because we are often working with the Ionic Storage which returns a Promise and not an Observable. It’s always good to know what the functions return so you can map/change the result according to what you need.
If you want a little course / intro on different RxJS functionalities just let me know below in the comments!
Back to topic. If we are online, our requests run through as desired and for a GET we store that information to our storage, for the important PUT we might want to catch any error case and store it locally then. This is a general assumption and of course you should not store any failed request!
Perhaps check a status or flag to determine if it’s worth caching or if the request was just bad.
All of this looks like the code below in action which goes to your app/services/api.service.ts:
import { OfflineManagerService } from './offline-manager.service'; import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { NetworkService, ConnectionStatus } from './network.service'; import { Storage } from '@ionic/storage'; import { Observable, from } from 'rxjs'; import { tap, map, catchError } from "rxjs/operators"; const API_STORAGE_KEY = 'specialkey'; const API_URL = 'https://reqres.in/api'; @Injectable({ providedIn: 'root' }) export class ApiService { constructor(private http: HttpClient, private networkService: NetworkService, private storage: Storage, private offlineManager: OfflineManagerService) { } getUsers(forceRefresh: boolean = false): Observable<any[]> { if (this.networkService.getCurrentNetworkStatus() == ConnectionStatus.Offline || !forceRefresh) { // Return the cached data from Storage return from(this.getLocalData('users')); } else { // Just to get some "random" data let page = Math.floor(Math.random() * Math.floor(6)); // Return real API data and store it locally return this.http.get(`${API_URL}/users?per_page=2&page=${page}`).pipe( map(res => res['data']), tap(res => { this.setLocalData('users', res); }) ) } } updateUser(user, data): Observable<any> { let url = `${API_URL}/users/${user}`; if (this.networkService.getCurrentNetworkStatus() == ConnectionStatus.Offline) { return from(this.offlineManager.storeRequest(url, 'PUT', data)); } else { return this.http.put(url, data).pipe( catchError(err => { this.offlineManager.storeRequest(url, 'PUT', data); throw new Error(err); }) ); } } // Save result of API requests private setLocalData(key, data) { this.storage.set(`${API_STORAGE_KEY}-${key}`, data); } // Get cached API result private getLocalData(key) { return this.storage.get(`${API_STORAGE_KEY}-${key}`); } }
Quite a lot of code for not seeing any results on the screen. But those 3 services are more or less a blueprint for your offline mode app so you can plug it in as easy as possible.
Using all Local & Cache Functionalities
Let’s wrap things up by using the different features that we have implemented previously.
First of all, let’s subscribe to our network connection state at the top level of our app so we can automatically run all stored requests when the user is back online. Again, once the Observable of checkForEvents()
returns we can be sure that all local requests have been processed.
To add the check, simply change your app/app.component.ts to:
import { NetworkService, ConnectionStatus } from './services/network.service'; import { Component } from '@angular/core'; import { Platform } from '@ionic/angular'; import { SplashScreen } from '@ionic-native/splash-screen/ngx'; import { StatusBar } from '@ionic-native/status-bar/ngx'; import { OfflineManagerService } from './services/offline-manager.service'; @Component({ selector: 'app-root', templateUrl: 'app.component.html' }) export class AppComponent { constructor( private platform: Platform, private splashScreen: SplashScreen, private statusBar: StatusBar, private offlineManager: OfflineManagerService, private networkService: NetworkService ) { this.initializeApp(); } initializeApp() { this.platform.ready().then(() => { this.statusBar.styleDefault(); this.splashScreen.hide(); this.networkService.onNetworkChange().subscribe((status: ConnectionStatus) => { if (status == ConnectionStatus.Online) { this.offlineManager.checkForEvents().subscribe(); } }); }); } }
Now we just need to get the real data by building tiny view and a simple page that makes the API calls.
In our case, the app/home/home.page.ts could look like this:
import { Platform } from '@ionic/angular'; import { Component, OnInit } from '@angular/core'; import { ApiService } from '../services/api.service'; @Component({ selector: 'app-home', templateUrl: 'home.page.html', styleUrls: ['home.page.scss'], }) export class HomePage implements OnInit { users = []; constructor(private apiService: ApiService, private plt: Platform) { } ngOnInit() { this.plt.ready().then(() => { this.loadData(true); }); } loadData(refresh = false, refresher?) { this.apiService.getUsers(refresh).subscribe(res => { this.users = res; if (refresher) { refresher.target.complete(); } }); } updateUser(id) { this.apiService.updateUser(id, {name: 'Simon', job: 'CEO'}).subscribe(); } }
There are not really any more words needed, we just use our service to get data or update data. And that’s the beauty of a good caching solution – the magic happens inside the service, not in your pages!
Here we can simply focus on the operations, so let’s finish everything by adding a simply dummy view inside our app/home/home.page.html:
<ion-header> <ion-toolbar> <ion-title> Ionic Offline Mode </ion-title> </ion-toolbar> </ion-header> <ion-content> <ion-refresher slot="fixed" (ionRefresh)="loadData(true, $event)"> <ion-refresher-content> </ion-refresher-content> </ion-refresher> <ion-list> <ion-item *ngFor="let user of users" tappable (click)="updateUser(user.id)"> <ion-thumbnail slot="start"> <img [src]="user.avatar"> </ion-thumbnail> <ion-label> <h3>{{ user.first_name }} {{ user.last_name }}</h3> </ion-label> </ion-item> </ion-list> </ion-content>
We add a refresher to have some force refresh mechanism in place (still, returning cached data when offline!) and a list of the users with a button to update them.
This app won’t get an award for it’s design nor functionality, but it definitely works when you once got data and then turn on your airplane mode! You can kill the app and start it again, the results will be retrieved from the storage and your offline manager requests queue just grows whenever you try to make the PUT request when your are offline until you come back online again.
Conclusion
In general caching is a bit tricky and there are not super many out of the box plugins that work for everyone. This post is one way to tackle the problem and hopefully shows that it’s definitely possible on one or another way depending on your requirements.
If you also want to store images, there’s a way of downloading them to your device and using them when you are offline. One way is using an image caching plugin like described here (again, not yet really working for v4 afaik), but I’ve recently come up with my own implementation of preloading all images of a request so let me know if that’s something you would be interested in!
You can also find a video version of this article below.
The post How to Build an Ionic 4 App with Offline Mode appeared first on Devdactic.