MediaComem / comem-citizen-engagement-ionic-setup

COMEM+ Citizen Engagement Ionic Setup

Home Page:https://github.com/MediaComem/comem-appmob

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

COMEM+ Citizen Engagement Ionic Setup

This repository contains instructions to build a skeleton application that can serve as a starting point to develop the Citizen Engagement mobile application. The completed skeleton app is available here.

This tutorial is used in the COMEM+ Mobile Applications course taught at HEIG-VD.

Prerequisites

These instructions assume that you are using the Citizen Engagement API described in the previous Web Services course, and that you are familiar with the documentation of the reference API.

You will need to have Node.js installed. The latest LTS (Long Term Support) version is recommended (v8.9.4 at the time of writing these instructions).

Back to top

Features

This guide describes a proposed list of features and a user interface based on those features. This is only a suggestion. You can support other features and make a different user interface.

The proposed app should allow citizens to do the following:

  • add new issues:
    • the issue should have a type and description;
    • the user should be able to take a photo of the issue;
    • the issue should be geolocated;
  • see existing issues on an interactive map;
  • browse the list of existing issues:
    • issues should be sorted by date;
  • see the details of an issue:
    • date;
    • description;
    • picture;
    • comments;
  • add comments to an issue.

The following sections describe a proposed UI mockup of the app and steps to set up a skeleton implementation.

Back to top

Design the user interface

Before diving into the code, you should always take a moment to design the user interface of your app. This doesn't have to be a final design, but it should at least be a sketch of what you want. This helps you think in terms of the whole app and of the relationships between screens.

UI Design

As you can see, we propose to use a tab view with 3 screens, and an additional 4th screen accessible from the issue list:

  • the new issue tab;
  • the issue map tab;
  • the issue list tab:
    • the issue details screen.

Now that we know what we want, we can start setting up the app!

Back to top

Set up the application

Create a blank Ionic app and make it a repository

Make sure you have Ionic and Cordova installed:

$> npm install -g ionic cordova

Go in the directory where you want the app, then generate a blank Ionic app with the following command:

$> cd /path/to/projects
$> ionic start citizen-engagement blank

? Would you like to integrate your new app with Cordova to target native iOS and Android? Yes
? Install the free Ionic Pro SDK and connect your app? No

Go into the app directory. The ionic start command should have already initialized a Git repository:

$> cd citizen-engagement
$> git log
commit 2a3f83f14ae2a82d00cb2b2960dda1c1e0b0a432 (HEAD -> master)
Author: John Doe <john.doe@example.com>
Date:   Mon Dec 18 10:00:01 2017 +0100

    Initial commit

Back to top

Serve the application locally

To make sure everything was set up correctly, use the following command from the repository to serve the application locally in your browser:

$> ionic serve

You should see something like this:

Serving the blank app

Back to top

Set up the navigation structure

As defined in our UI design, we want the following 4 screens:

  • the issue creation tab;
  • the issue map tab;
  • the issue list tab:
    • the issue details screen.

Let's start by setting up the 3 tabs. We will use Ionic's Tabs component.

Back to top

Create the pages

Each page will be an Angular component. Ionic has a generate command that can automatically set up the files we need to create each page's component:

$> ionic generate page --no-module CreateIssue
$> ionic generate page --no-module IssueMap
$> ionic generate page --no-module IssueList

This will generate the following files:

src/pages/create-issue/create-issue.html
src/pages/create-issue/create-issue.scss
src/pages/create-issue/create-issue.ts
src/pages/issue-map/issue-map.html
src/pages/issue-map/issue-map.scss
src/pages/issue-map/issue-map.ts
src/pages/issue-list/issue-list.html
src/pages/issue-list/issue-list.scss
src/pages/issue-list/issue-list.ts

For each page, we have:

  • An HTML template.
  • A Sass/SCSS stylesheet.
  • An Angular component.

Now update the HTML template for each page and add some content within the <ion-content> tag. For example, in src/pages/create-issue/create-issue.html:

<ion-content padding>
  Let's create an issue.
</ion-content>

Update the app to use the pages

Now that the pages are ready, we need to display them.

First, Angular and Ionic need to be aware of your new components. You must declare them in 2 places in your main Angular module in src/app/app.module.ts:

  • Add them to the declarations array to register the components with Angular.
  • Add them to the entryComponents array so that Ionic is able to inject them dynamically (e.g. when switching tabs).
// Other imports...
// TODO: import the new components.
import { CreateIssuePage } from '../pages/create-issue/create-issue';
import { IssueListPage } from '../pages/issue-list/issue-list';
import { IssueMapPage } from '../pages/issue-map/issue-map';

@NgModule({
  declarations: [
    MyApp,
    HomePage,
    CreateIssuePage, // TODO: add the components to "declarations".
    IssueListPage,
    IssueMapPage
  ],
  imports: [ /* ... */ ],
  bootstrap: [ /* ... */ ],
  entryComponents: [
    MyApp,
    HomePage,
    CreateIssuePage, // TODO: add the components to "entryComponents".
    IssueListPage,
    IssueMapPage
  ],
  providers: [ /* ... */ ]
})
export class AppModule {}

Second, update the home page's component (src/pages/home/home.ts) to include the list of tabs we want:

// Other imports...
// TODO: import the new components.
import { CreateIssuePage } from '../create-issue/create-issue';
import { IssueMapPage } from '../issue-map/issue-map';
import { IssueListPage } from '../issue-list/issue-list';

// TODO: add an interface to represent a tab.
export interface HomePageTab {
  title: string;
  icon: string;
  component: Function;
}

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {

  // TODO: declare a list of tabs to the component.
  tabs: HomePageTab[];

  constructor(public navCtrl: NavController) {
    // TODO: define some tabs.
    this.tabs = [
      { title: 'New Issue', icon: 'add', component: CreateIssuePage },
      { title: 'Issue Map', icon: 'map', component: IssueMapPage },
      { title: 'Issue List', icon: 'list', component: IssueListPage }
    ];
  }

}

Third, we will replace the entire contents of the home page's template (src/pages/home/home.html) to use Ionic's Tabs component.

Angular's ngFor directive allows us to iterate over the tabs array we declared in the home page's component, and to put one <ion-tab> tag in the page for each component:

<ion-tabs>
  <ion-tab *ngFor='let tab of tabs'
           [tabTitle]='tab.title' [tabIcon]='tab.icon' [root]='tab.component'>
  </ion-tab>
</ion-tabs>

You should now be able to navigate between the 3 tabs!

Set up security

To use the app, a citizen should identify him- or herself. You will add a login screen that the user must go through before accessing the other screens. Authentication will be performed by the Citizen Engagement API.

The API requires a bearer token be sent to identify the user when making requests on some resources (e.g. when creating issues). This token must be sent in the Authorization header for all requests requiring identification. Once login/logout is implemented, you will also set up an HTTP interceptor to automatically add this header to every request.

Check the documentation of the API's authentication resource

The Citizen Engagement API provides an /auth resource on which you can make a POST request to authenticate.

You need to make a call that looks like this:

POST /api/auth HTTP/1.1
Content-Type: application/json

{
  "name": "jdoe",
  "password": "test"
}

The response will contain the token we need for authentication, as well as a representation of the authenticated user:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "token": "eyJhbGciOiJIU.eyJpc3MiOiI1OGM1YjUwZTA0Nm.gik21xyT4_NzsduWMLVp8",
  "user": {
    "firstname": "John",
    "id": "58c5b50e046ea004e4af9d32",
    "lastname": "Doe",
    "name": "jdoe",
    "roles": [
      "citizen"
    ]
  }
}

You will need to perform this request and retrieve that information when the user logs in.

Back to top

Create model classes

Let's create a few classes to use as models when communicating with the API. That way we will benefit from TypeScript's typing when accessing model properties.

Create a src/models/user.ts file which exports a model representing a user of the API:

export class User {
  id: string;
  href: string;
  name: string;
  firstname: string;
  lastname: string;
  roles: string[];
}

Create a src/models/auth-request.ts file which exports a model representing a request to the authentication resource:

export class AuthRequest {
  name: string;
  password: string;
}

Create a src/models/auth-response.ts file which exports a model representing a successful response from the authentication resource:

import { User } from './user';

export class AuthResponse {
  token: string;
  user: User;
}

Create an authentication service

Let's generate a reusable, injectable service to manage authentication:

$> ionic generate provider Auth

Register the new provider in the module's providers array in src/app/app.module.ts. You also need to add Angular's HttpClientModule to the module's imports array because the provider uses HttpClient:

// Other imports...
// TODO: import Angular's HttpClientModule and the new provider.
import { HttpClientModule } from '@angular/common/http';
import { AuthProvider } from '../providers/auth/auth';

@NgModule({
  declarations: [ /* ... */ ],
  imports: [
    // ...
    // TODO: import Angular's HttpClientModule.
    HttpClientModule
  ],
  bootstrap: [ /* ... */ ],
  entryComponents: [ /* ... */],
  providers: [
    // ...
    // TODO: register the new provider.
    AuthProvider
  ]
})
export class AppModule {}

You can replace the contents of the generated srv/providers/auth/auth.ts file with the following code:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Response } from '@angular/http';
import { Observable, ReplaySubject } from 'rxjs/Rx';
import { map } from 'rxjs/operators';

import { AuthRequest } from '../../models/auth-request';
import { AuthResponse } from '../../models/auth-response';
import { User } from '../../models/user';

/**
 * Authentication service for login/logout.
 */
@Injectable()
export class AuthProvider {

  private auth$: Observable<AuthResponse>;
  private authSource: ReplaySubject<AuthResponse>;

  constructor(private http: HttpClient) {
    this.authSource = new ReplaySubject(1);
    this.authSource.next(undefined);
    this.auth$ = this.authSource.asObservable();
  }

  isAuthenticated(): Observable<boolean> {
    return this.auth$.pipe(map(auth => !!auth));
  }

  getUser(): Observable<User> {
    return this.auth$.pipe(map(auth => auth ? auth.user : undefined));
  }

  getToken(): Observable<string> {
    return this.auth$.pipe(map(auth => auth ? auth.token : undefined));
  }

  logIn(authRequest: AuthRequest): Observable<User> {

    const authUrl = 'https://comem-citizen-engagement.herokuapp.com/api/auth';
    return this.http.post<AuthResponse>(authUrl, authRequest).pipe(
      map(auth => {
        this.authSource.next(auth);
        console.log(`User ${auth.user.name} logged in`);
        return auth.user;
      })
    );
  }

  logOut() {
    this.authSource.next(null);
    console.log('User logged out');
  }

}

Create the login screen

Generate a login page component:

$> ionic generate page --no-module Login

Add this new component to the module's declarations and entryComponents arrays in src/app/app.module.ts:

// Other imports...
// TODO: import the new component.
import { LoginPage } from '../pages/login/login';

@NgModule({
  declarations: [
    // ...
    // TODO: add the component to the declarations.
    LoginPage
  ],
  imports: [ /* ... */ ],
  bootstrap: [ /* ... */ ],
  entryComponents: [
    // ...
    // TODO: add the component to the entry components.
    LoginPage
  ],
  providers: [ /* ... */ ]
})
export class AppModule {}

Add the following HTML form inside the <ion-content> tag of src/pages/login/login.html:

<form (submit)='onSubmit($event)'>
  <ion-list>

    <!-- Name input -->
    <ion-item>
      <ion-label floating>Name</ion-label>
      <ion-input type='text' name='name'
                 #nameInput='ngModel' [(ngModel)]='authRequest.name' required></ion-input>
    </ion-item>

    <!-- Error message displayed if the name is invalid -->
    <ion-item *ngIf='nameInput.invalid && nameInput.dirty' no-lines>
      <p ion-text color='danger'>Name is required.</p>
    </ion-item>

    <!-- Password input -->
    <ion-item>
      <ion-label floating>Password</ion-label>
      <ion-input type='password' name='password'
                 #passwordInput='ngModel' [(ngModel)]='authRequest.password' required></ion-input>
    </ion-item>

    <!-- Error message displayed if the password is invalid -->
    <ion-item *ngIf='passwordInput.invalid && passwordInput.dirty' no-lines>
      <p ion-text color='danger'>Password is required.</p>
    </ion-item>

  </ion-list>

  <div padding>

    <!-- Submit button -->
    <button type='submit' [disabled]='form.invalid' ion-button block>Log in</button>

    <!-- Error message displayed if the login failed -->
    <p *ngIf='loginError' ion-text color='danger'>Name or password is invalid.</p>

  </div>
</form>

Update src/pages/login/login.ts as follows:

import { Component, ViewChild } from '@angular/core';
import { NgForm } from '@angular/forms';
import { NavController, NavParams } from 'ionic-angular';

import { AuthRequest } from '../../models/auth-request';
import { AuthProvider } from '../../providers/auth/auth';
import { HomePage } from '../home/home';

/**
 * Login page.
 *
 * See https://ionicframework.com/docs/components/#navigation for more info on
 * Ionic pages and navigation.
 */
@Component({
  templateUrl: 'login.html'
})
export class LoginPage {

  /**
   * This authentication request object will be updated when the user
   * edits the login form. It will then be sent to the API.
   */
  authRequest: AuthRequest;

  /**
   * If true, it means that the authentication API has return a failed response
   * (probably because the name or password is incorrect).
   */
  loginError: boolean;

  /**
   * The login form.
   */
  @ViewChild(NgForm)
  form: NgForm;

  constructor(private auth: AuthProvider, private navCtrl: NavController) {
    this.authRequest = new AuthRequest();
  }

  /**
   * Called when the login form is submitted.
   */
  onSubmit($event) {

    // Prevent default HTML form behavior.
    $event.preventDefault();

    // Do not do anything if the form is invalid.
    if (this.form.invalid) {
      return;
    }

    // Hide any previous login error.
    this.loginError = false;

    // Perform the authentication request to the API.
    this.auth.logIn(this.authRequest).subscribe(undefined, err => {
      this.loginError = true;
      console.warn(`Authentication failed: ${err.message}`);
    });
  }
}

Back to top

Use the authentication service to protect access to the home page

Add the following imports to src/app/app.component.ts:

import { LoginPage } from '../pages/login/login';
import { AuthProvider } from '../providers/auth/auth';

Make sure that it doesn't have a default home page any more:

// ...
export class MyApp {
  rootPage: any;
  // ...
}

Update the component's constructor as follows:

constructor(
  // TODO: inject the authentication provider.
  private auth: AuthProvider,
  platform: Platform,
  statusBar: StatusBar,
  splashScreen: SplashScreen
) {

  // TODO: redirect the user to the login page if not authenticated.
  // Direct the user to the correct page depending on whether he or she is logged in.
  this.auth.isAuthenticated().subscribe(authenticated => {
    if (authenticated) {
      this.rootPage = HomePage;
    } else {
      this.rootPage = LoginPage;
    }
  });

  // ...
}

The login screen is ready! If you reload your app, you should see that you are automatically redirected to the login page.

You can now log in. You should be able to use the username jdoe and the password test with the Citizen Engagement API's standard dataset.

Back to top

Storing the authentication credentials

Now you can log in, but there's a little problem. Every time the app is reloaded, you lose all data so you have to log back in. This is particularly annoying for local development since the browser is automatically refreshed every time you change the code.

You need to use more persistent storage for the security credentials, i.e. the authentication token. Ionic provides a storage module which will automatically select an appropriate storage method for your platform. It will use SQLite on phones when available; for web platforms it will use IndexedDB, WebSQL or Local Storage.

To use the Ionic storage module, you must import it into your application's module in src/app/app.module.ts:

// Other imports...
// TODO: import the ionic storage module.
import { IonicStorageModule } from '@ionic/storage';

@NgModule({
  // ...
  imports: [
    // ...
    // TODO: import the ionic storage module into the app's module.
    IonicStorageModule.forRoot()
  ],
  // ...
})
export class AppModule {}

Now you can import the Storage provider in AuthProvider in src/providers/auth/auth.ts:

// Other imports...
// TODO: import RxJS's delayWhen operator and Ionic's storage provider.
import { delayWhen, map } from 'rxjs/operators';
import { Storage } from '@ionic/storage';

You also need to inject it into the constructor:

constructor(private http: HttpClient, private storage: Storage)

Add a method to persist the authentication information using the storage module:

private saveAuth(auth: AuthResponse): Observable<void> {
  return Observable.fromPromise(this.storage.set('auth', auth));
}

The storage module returns Promises, but we'll be plugging this new function into logIn() which uses Observables, so we convert the Promise to an Observable before returning it.

You can now update the logIn() method to persist the API's authentication response with the new saveAuth() method. To do that, use RxJS's delayWhen operator, which allows us to delay an Observable stream until another Observable emits (in this case, the one that saves the authentication response):

logIn(authRequest: AuthRequest): Observable<User> {

  const authUrl = 'https://comem-citizen-engagement.herokuapp.com/api/auth';
  return this.http.post<AuthResponse>(authUrl, authRequest).pipe(
    // TODO: delay the observable stream while persisting the authentication response.
    delayWhen(auth => {
      return this.saveAuth(auth);
    }),
    map(auth => {
      this.authSource.next(auth);
      console.log(`User ${auth.user.name} logged in`);
      return auth.user;
    })
  );
}

When testing in the browser, you should already see the object being stored in IndexedDB (the default storage if using Chrome).

You must now load it when the app starts. You can do that in the constructor of AuthProvider.

Since the storage provider's get method returns a promise, you can only use the result in a .then asynchronous callback:

constructor(private http: HttpClient, private storage: Storage) {

  this.authSource = new ReplaySubject(1);
  this.auth$ = this.authSource.asObservable();

  // TODO: load the stored authentication response from storage when the app starts.
  this.storage.get('auth').then(auth => {
    // Push the loaded value into the observable stream.
    this.authSource.next(auth);
  });
}

Your app should now remember user credentials even when you reload it!

Finally, also update the authentication provider's logOut() method to remove the stored authentication from storage:

logOut() {
  this.authSource.next(null);
  // TODO: remove the stored authentication response from storage when logging out.
  this.storage.remove('auth');
  console.log('User logged out');
}

Back to top

Log out

You should also add a UI component to allow the user to log out. As an example, you will display a logout button in the issue creation screen.

Add an <ion-buttons> tag with a logout button in src/pages/create-issue/create-issue.html:

<ion-navbar>
  <ion-title>CreateIssue</ion-title>

  <!-- Logout button -->
  <ion-buttons end>
    <button ion-button icon-only (click)='logOut()'>
      <ion-icon name='log-out'></ion-icon>
    </button>
  </ion-buttons>
</ion-navbar>

Let's assume that when logging out, we want the user redirected to the login page, and we want the navigation stack to be cleared (so that pressing the back button doesn't bring the user back to a protected screen).

To do that, you will need:

  • To inject the Ionic application (App from the ionic-angular package), which will allow you to set the root page and thereby clear the navigation stack.
  • To add a logOut method to the CreateIssuePage component, since it's what we call in its HTML template above.
  • To inject AuthProvider and use its logOut method.

After doing all that, your CreateIssuePage component should look something like this:

// Other imports...
// TODO: import the authentication provider and login page.
import { AuthProvider } from '../../providers/auth/auth';
import { LoginPage } from '../login/login';

@Component({
  selector: 'page-create-issue',
  templateUrl: 'create-issue.html'
})
export class CreateIssuePage {

  constructor(
    // TODO: inject the authentication provider.
    private auth: AuthProvider,
    public navCtrl: NavController,
    public navParams: NavParams
  ) {
  }

  // TODO: add a method to log out.
  logOut() {
    this.auth.logOut();
  }

}

You should now see the logout button in the navigation bar after logging in.

You might want to encapsulate it into a reusable component later, to include in other screens.

Back to top

Authentication observable magic

Note that we saved ourselves a bit of trouble by implementing authentication with Observables in the main component:

// Direct the user to the correct page depending on whether he or she is logged in.
this.auth.isAuthenticated().subscribe(authenticated => {
  if (authenticated) {
    this.rootPage = HomePage;
  } else {
    this.rootPage = LoginPage;
  }
});

It subscribes to the Observable authentication stream when the app starts and keeps listening to events, meaning that it will get notified of any change in the current authentication status:

  • When a user logs in, the subscription callback is called with true to indicate that a user logged in, and the user is redirected to the HomePage.
  • When a user logs out, the subscription callback is called again with false, and the user is redirected to the LoginPage.

That way, we didn't have to explicitly add some more code to redirect the user to the login page when called the authentication provider's logOut() function from the CreateIssuePage component's logOut() method.

Back to top

Configuring an HTTP interceptor

Now that you have login and logout functionality, and an authentication service that stores an authentication token, you can authenticate for other API calls.

Looking at the API documentation, at some point you will need to create an issue. The documentation states that you must send a bearer token in the Authorization header, like this:

POST /api/issues HTTP/1.1
Authorization: Bearer 0a98wumv
Content-Type: application/json

{"some":"json"}

With Angular, you would make this call like this:

httpClient.post('http://example.com/path', body, {
  headers: {
    Authorization: `Bearer ${token}`
  }
});

But it's a bit annoying to have to specify this header for every request. After all, we know that we need it for most calls.

Interceptors are Angular services that can be registered with the HTTP client to automatically react to requests (or responses). This solves our problem: we want to register an interceptor that will automatically add the Authorization header to all requests if the user is logged in.

To demonstrate that it works, start by adding a call to list issues in the CreateIssuePage component in src/pages/create-issue/create-issue.ts:

// Other imports...
// TODO: import Angular's HTTP client.
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'page-create-issue',
  templateUrl: 'create-issue.html'
})
export class CreateIssuePage {

  constructor(
    private auth: AuthProvider,
    // TODO: inject the HTTP client.
    public http: HttpClient,
    public navCtrl: NavController,
    public navParams: NavParams
  ) {
  }

  ionViewDidLoad() {
    // TODO: make an HTTP request to retrieve the issue types.
    const url = 'https://comem-citizen-engagement.herokuapp.com/api/issueTypes';
    this.http.get(url).subscribe(issueTypes => {
      console.log(`Issue types loaded`, issueTypes);
    });
  }

  // ...

}

If you display the issue list page and check network requests in Chrome's developer tools, you will see that there is no Authorization header sent even when the user is logged in.

Now you can generate the interceptor service:

$> ionic generate provider AuthInterceptor

Put the following contents in the generated src/providers/auth-interceptor/auth-interceptor.ts file:

import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Injectable, Injector } from '@angular/core';
import { Observable } from 'rxjs/Rx';
import { first, switchMap } from 'rxjs/operators';

import { AuthProvider } from '../auth/auth';

@Injectable()
export class AuthInterceptorProvider implements HttpInterceptor {

  constructor(private injector: Injector) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    // Retrieve AuthProvider at runtime from the injector.
    // (Otherwise there would be a circular dependency:
    //  AuthInterceptorProvider -> AuthProvider -> HttpClient -> AuthInterceptorProvider).
    const auth = this.injector.get(AuthProvider);

    // Get the bearer token (if any).
    return auth.getToken().pipe(
      first(),
      switchMap(token => {

        // Add it to the request if it doesn't already have an Authorization header.
        if (token && !req.headers.has('Authorization')) {
          req = req.clone({
            headers: req.headers.set('Authorization', `Bearer ${token}`)
          });
        }

        return next.handle(req);
      })
    );
  }

}

Now you simply need to register the interceptor in your application module. Since it's an HTTP interceptor, it's not like other providers and must be registered in a special way. In src/app/app.module.ts, add:

// Other imports...
import { AuthInterceptorProvider } from '../providers/auth-interceptor/auth-interceptor';

@NgModule({
  // ...
  providers: [
    // Other providers...
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptorProvider, multi: true }
  ]
})
export class AppModule {}

The multi: true option is necessary because you can register multiple interceptors if you want (read more about multi providers).

Now all your API calls will have the Authorization header when the user is logged in.

Back to top

Multi-environment & sensitive configuration

Sometimes you might have to store values that should not be committed to version control:

  • Environment-specific values that may change depending on how you deploy your app (e.g. the URL of your API).
  • Sensitive information like access tokens or passwords.

For example, in our earlier HTTP calls, the URL was hardcoded:

const url = 'https://comem-citizen-engagement.herokuapp.com/api/issueTypes';
this.http.get(url).subscribe(issueTypes => {
  // ...
});

This is not optimal considering the multi-environment problem. If you wanted to change environments, you would have to manually change the URL every time.

Let's find a way to centralize this configuration.

Back to top

Create a sample configuration file

Create a src/app/config.sample.ts file. This file is a placeholder which should NOT contain the actual configuration. Its purpose is to explain to other developers of the project that they should create a config.ts file and fill in the actual values:

// Copy this file to config.ts and fill in appropriate values.
export const config = {
  apiUrl: 'https://example.com/api'
}

Back to top

Create the actual configuration file

You can now create the actual src/app/config.ts configuration file, this time containing the actual configuration values:

export const config = {
  apiUrl: 'https://comem-citizen-engagement.herokuapp.com/api'
}

Back to top

Add the configuration file to your .gitignore file

Of course, you don't want to commit config.ts, but you do want to commit config.sample.ts so that anyone who clones your project can see what configuration options are required. Add these 2 lines to your .gitignore file:

src/app/config.*
!src/app/config.sample.ts

The first line ignores any config.* file in the src/app directory. The second line adds an exception: that the config.sample.ts should not be ignored. You now have your uncommitted configuration file!

Not only that, but if you often need to quickly swap between different configurations, you may create several versions of the configuration file, like config.dev.ts or config.prod.ts, which will all be ignored by Git. Everytime you need to use one of them, simply overwrite config.ts with the correct one:

$> cp src/app/config.dev.ts src/app/config.ts
$> cp src/app/config.prod.ts src/app/config.ts

This is something you could put in your package.json's scripts section:

"scripts": {
  "dev": "cp src/app/config.dev.ts src/app/config.ts",
  "prod": "cp src/app/config.prod.ts src/app/config.ts",
  "...": "..."
}

Back to top

Feed the configuration to Angular

Now that you have your configuration files, you want to use its values in code.

Since it's a TypeScript file like any other, you simply have to import and use it, for example in src/pages/create-issue/create-issue.ts:

// Other imports...
// TODO: import the configuration.
import { config } from '../../app/config';

// ...
export class CreateIssuePage {
  // ...
  ionViewDidLoad() {
    console.log('ionViewDidLoad CreateIssuePage');

    // TODO: replace the hardcoded API URL by the one from the configuration.
    const url = `${config.apiUrl}/issueTypes`;
    this.httpClient.get(url).subscribe(issueTypes => {
      console.log('Issue types loaded', issueTypes);
    });
  }
  // ...
}

Do not forget to also update the authentication provider in src/providers/auth/auth.ts, which also has a hardcoded URL:

// Other imports...
// TODO: import the configuration.
import { config } from '../../app/config';

// ...
export class AuthProvider {
  // ...
  logIn(authRequest: AuthRequest): Observable<User> {

    // TODO: replace the hardcoded API URL by the one from the configuration.
    const authUrl = `${config.apiUrl}/auth`;
    // ...
  }
  // ...
}

Back to top

Troubleshooting

ionic serve crashes with an ECONNRESET error when saving a file

The output of ionic serve after saving a file:

[10:43:01]  build started ...
[10:43:01]  deeplinks update started ...
[10:43:01]  deeplinks update finished in 2 ms
[10:43:01]  transpile started ...
[10:43:02]  transpile finished in 609 ms
[10:43:02]  webpack update started ...
[10:43:05]  webpack update finished in 3.17 s
[10:43:05]  sass update started ...
[10:43:06]  sass update finished in 682 ms
[10:43:06]  build finished in 4.46 s

events.js:183
      throw er; // Unhandled 'error' event
      ^

Error: read ECONNRESET
    at _errnoException (util.js:1024:11)
    at TCP.onread (net.js:615:25)

This is due to a deprecation in the Web Sockets library. As a workaround until this is fixed in Ionic, downgrade the library:

npm i -D -E ws@3.3.2

Reference:

Cordova doesn't want JDK 1.9

$> ionic cordova run android
Running app-scripts build: --platform android --target cordova
[10:14:06]  build dev started ...
[10:14:06]  clean started ...
[10:14:06]  clean finished in 2 ms
[10:14:06]  copy started ...
[10:14:06]  deeplinks started ...
[10:14:06]  deeplinks finished in 36 ms
[10:14:06]  transpile started ...
[10:14:08]  transpile finished in 2.39 s
[10:14:08]  preprocess started ...
[10:14:08]  preprocess finished in less than 1 ms
[10:14:08]  webpack started ...
[10:14:08]  copy finished in 2.53 s
[10:14:13]  webpack finished in 4.51 s
[10:14:13]  sass started ...
[10:14:13]  sass finished in 709 ms
[10:14:13]  postprocess started ...
[10:14:13]  postprocess finished in 4 ms
[10:14:13]  lint started ...
[10:14:13]  build dev finished in 7.73 s
> cordova run android

You have been opted out of telemetry. To change this, run: cordova telemetry on.
Android Studio project detected

ANDROID_HOME=/Users/jdoe/Library/Android/sdk
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-9.jdk/Contents/Home
(node:6282) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): CordovaError: Requirements check failed for JDK 1.8 or greater
(node:6282) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

[OK] Your app has been deployed.
     Did you know you can live-reload changes from your app with --livereload?

[10:14:15]  lint finished in 1.98 s

At the time of writing these instructions, Android and Cordova do not support version 9 of the JDK.

Install the JDK 8, find its full path, and put it first in your path when running ionic or cordova:

# For example, on macOS
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_161.jdk/Contents/Home

Alternatively, add that line to your .bash_profile to do it automatically.

Reference:

About

COMEM+ Citizen Engagement Ionic Setup

https://github.com/MediaComem/comem-appmob

License:MIT License