Fork me on GitHub

WhatsApp Clone with Meteor and Ionic 2 CLI

Ionic 3 Version (Last Update: 2017-06-15)

Google Maps & Geolocation

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

In this step we will add the ability to send the current location in Google Maps.

Geo Location

To get the devices location (aka geo-location) we will install a Cordova plug-in called cordova-plugin-geolocation:

$ ionic cordova plugin add cordova-plugin-geolocation --save
$ npm install --save @ionic-native/geolocation

If you use Chromium you may get the following error: Network location provider at 'https://www.googleapis.com/' : Returned error code 403. Since chromium 23, some features were slowly moved to require Google API keys in order to function. In a precompiled version of chrome or chromium nightly or canary, everything seems to work, because these builds use default keys. But if we build chromium by ourselves, it won't have any keys and functions like Google Maps Geolocation API, Sync API, etc. won't function properly. See also issue 179686.

Angular 2 Google Maps

Since the location is going to be presented with Google Maps, we will install a package which will help up interact with it in Angular 2:

$ npm install --save @agm/core

Before you import the installed package to the app's NgModule be sure to generate an API key. An API key is a code passed in by computer programs calling an API to identify the calling program, its developer, or its user to the Web site. To generate an API key go to Google Maps API documentation page and follow the instructions. Each app should have it's own API key, as for now we can just use an API key we generated for the sake of this tutorial, but once you are ready for production, replace the API key in the script below:

12.3 Import google maps module src/app/app.module.ts
3
4
5
6
7
8
9
 
31
32
33
34
35
36
37
38
39
40
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { AgmCoreModule } from '@agm/core';
import { MomentModule } from 'angular2-moment';
import { ChatsPage } from '../pages/chats/chats';
import { NewChatComponent } from '../pages/chats/new-chat';
...some lines skipped...
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    MomentModule,
    AgmCoreModule.forRoot({
      apiKey: 'AIzaSyAWoBdZHCNh5R-hB5S5ZZ2oeoYyfdDgniA'
    })
  ],
  bootstrap: [IonicApp],
  entryComponents: [

Attachments Menu

Before we proceed any further, we will add a new message type to our schema, so we can differentiate between a text message and a location message:

12.4 Added location message type api/server/models.ts
6
7
8
9
10
11
12
13
}
 
export enum MessageType {
  TEXT = <any>'text',
  LOCATION = <any>'location'
}
 
export interface Chat {

We want the user to be able to send a location message through an attachments menu in the MessagesPage, so let's implement the initial MessagesAttachmentsComponent, and as we go through, we will start filling it up:

12.5 Added stub for messages attachment menu src/pages/messages/messages-attachments.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Component } from '@angular/core';
import { ModalController, ViewController } from 'ionic-angular';
 
@Component({
  selector: 'messages-attachments',
  templateUrl: 'messages-attachments.html'
})
export class MessagesAttachmentsComponent {
  constructor(
    private viewCtrl: ViewController,
    private modelCtrl: ModalController
  ) {}
}
12.6 Added messages attachment menu template src/pages/messages/messages-attachments.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<ion-content class="messages-attachments-page-content">
  <ion-list class="attachments">
    <button ion-item class="attachment attachment-gallery">
      <ion-icon name="images" class="attachment-icon"></ion-icon>
      <div class="attachment-name">Gallery</div>
    </button>
 
    <button ion-item class="attachment attachment-camera">
      <ion-icon name="camera" class="attachment-icon"></ion-icon>
      <div class="attachment-name">Camera</div>
    </button>
 
    <button ion-item class="attachment attachment-location">
      <ion-icon name="locate" class="attachment-icon"></ion-icon>
      <div class="attachment-name">Location</div>
    </button>
  </ion-list>
</ion-content>
12.7 Added styles for messages attachment src/pages/messages/messages-attachments.scss
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
.messages-attachments-page-content {
  $icon-background-size: 60px;
  $icon-font-size: 20pt;
 
  .attachments {
    width: 100%;
    margin: 0;
    display: inline-flex;
  }
 
  .attachment {
    text-align: center;
    margin: 0;
    padding: 0;
 
    .item-inner {
      padding: 0
    }
 
    .attachment-icon {
      width: $icon-background-size;
      height: $icon-background-size;
      line-height: $icon-background-size;
      font-size: $icon-font-size;
      border-radius: 50%;
      color: white;
      margin-bottom: 10px
    }
 
    .attachment-name {
      color: gray;
    }
  }
 
  .attachment-gallery .attachment-icon {
    background: linear-gradient(#e13838 50%, #f53d3d 50%);
  }
 
  .attachment-camera .attachment-icon {
    background: linear-gradient(#3474e1 50%, #387ef5 50%);
  }
 
  .attachment-location .attachment-icon {
    background: linear-gradient(#2ec95c 50%, #32db64 50%);
  }
}
12.8 Import MessagesAttachmentsComponent src/app/app.module.ts
10
11
12
13
14
15
16
 
27
28
29
30
31
32
33
34
 
48
49
50
51
52
53
54
55
import { ChatsOptionsComponent } from '../pages/chats/chats-options';
import { LoginPage } from '../pages/login/login';
import { MessagesPage } from '../pages/messages/messages';
import { MessagesAttachmentsComponent } from '../pages/messages/messages-attachments';
import { MessagesOptionsComponent } from '../pages/messages/messages-options';
import { ProfilePage } from '../pages/profile/profile';
import { VerificationPage } from '../pages/verification/verification';
...some lines skipped...
    ProfilePage,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent,
    MessagesAttachmentsComponent
  ],
  imports: [
    BrowserModule,
...some lines skipped...
    ProfilePage,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent,
    MessagesAttachmentsComponent
  ],
  providers: [
    StatusBar,

We will add a generic style-sheet for the attachments menu since it can also use us in the future:

12.9 Added styles for the popover container src/app/app.scss
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
  left: calc(100% - #{$options-popover-width} - #{$options-popover-margin}) !important;
  top: $options-popover-margin !important;
}
 
// Attachments Popover Component
// --------------------------------------------------
 
$attachments-popover-width: 100%;
 
.attachments-popover .popover-content {
  width: $attachments-popover-width;
  transform-origin: 300px 30px !important;
  left: calc(100% - #{$attachments-popover-width}) !important;
  top: 58px !important;
}

Now we will add a handler in the MessagesPage which will open the newly created menu, and we will bind it to the view:

12.10 Add showAttachments method src/pages/messages/messages.ts
7
8
9
10
11
12
13
 
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
import * as _ from 'lodash';
import { MessagesOptionsComponent } from './messages-options';
import { Subscription, Observable, Subscriber } from 'rxjs';
import { MessagesAttachmentsComponent } from './messages-attachments';
 
@Component({
  selector: 'messages-page',
...some lines skipped...
      this.message = '';
    });
  }
 
  showAttachments(): void {
    const popover = this.popoverCtrl.create(MessagesAttachmentsComponent, {
      chat: this.selectedChat
    }, {
      cssClass: 'attachments-popover'
    });
 
    popover.onDidDismiss((params) => {
      // TODO: Handle result
    });
 
    popover.present();
  }
}
12.11 Bind click event to showAttachments src/pages/messages/messages.html
7
8
9
10
11
12
13
    <ion-title class="chat-title">{{title}}</ion-title>
 
    <ion-buttons end>
      <button ion-button icon-only class="attach-button" (click)="showAttachments()"><ion-icon name="attach"></ion-icon></button>
      <button ion-button icon-only class="options-button" (click)="showOptions()"><ion-icon name="more"></ion-icon></button>
    </ion-buttons>
  </ion-navbar>

Sending Location

A location is a composition of longitude, latitude and an altitude, or in short: long, lat, alt. Let's define a new Location model which will represent the mentioned schema:

12.12 Added location model api/server/models.ts
31
32
33
34
35
36
37
38
39
export interface User extends Meteor.User {
  profile?: Profile;
}
 
export interface Location {
  lat: number;
  lng: number;
  zoom: number;
}

Up next, would be implementing the actual component which will handle geo-location sharing:

12.13 Implement location message component src/app/app.module.ts
3
4
5
6
7
8
9
 
55
56
57
58
59
60
61
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { Geolocation } from '@ionic-native/geolocation';
import { AgmCoreModule } from '@agm/core';
import { MomentModule } from 'angular2-moment';
import { ChatsPage } from '../pages/chats/chats';
...some lines skipped...
  providers: [
    StatusBar,
    SplashScreen,
    Geolocation,
    {provide: ErrorHandler, useClass: IonicErrorHandler},
    PhoneService
  ]
12.13 Implement location message component src/pages/messages/location-message.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Platform, ViewController } from 'ionic-angular';
import { Geolocation } from '@ionic-native/geolocation';
import { Location } from 'api/models';
import { Observable, Subscription } from 'rxjs';
 
const DEFAULT_ZOOM = 8;
const EQUATOR = 40075004;
const DEFAULT_LAT = 51.678418;
const DEFAULT_LNG = 7.809007;
const LOCATION_REFRESH_INTERVAL = 500;
 
@Component({
  selector: 'location-message',
  templateUrl: 'location-message.html'
})
export class NewLocationMessageComponent implements OnInit, OnDestroy {
  lat: number = DEFAULT_LAT;
  lng: number = DEFAULT_LNG;
  zoom: number = DEFAULT_ZOOM;
  accuracy: number = -1;
  intervalObs: Subscription;
 
  constructor(private platform: Platform,
              private viewCtrl: ViewController,
              private geolocation: Geolocation) {
  }
 
  ngOnInit() {
    // Refresh location at a specific refresh rate
    this.intervalObs = this.reloadLocation()
      .flatMapTo(Observable
        .interval(LOCATION_REFRESH_INTERVAL)
        .timeInterval())
      .subscribe(() => {
        this.reloadLocation();
      });
  }
 
  ngOnDestroy() {
    // Dispose subscription
    if (this.intervalObs) {
      this.intervalObs.unsubscribe();
    }
  }
 
  calculateZoomByAccureacy(accuracy: number): number {
    // Source: http://stackoverflow.com/a/25143326
    const deviceHeight = this.platform.height();
    const deviceWidth = this.platform.width();
    const screenSize = Math.min(deviceWidth, deviceHeight);
    const requiredMpp = accuracy / screenSize;
 
    return ((Math.log(EQUATOR / (256 * requiredMpp))) / Math.log(2)) + 1;
  }
 
  reloadLocation() {
    return Observable.fromPromise(this.geolocation.getCurrentPosition().then((position) => {
      if (this.lat && this.lng) {
        // Update view-models to represent the current geo-location
        this.accuracy = position.coords.accuracy;
        this.lat = position.coords.latitude;
        this.lng = position.coords.longitude;
        this.zoom = this.calculateZoomByAccureacy(this.accuracy);
      }
    }));
  }
 
  sendLocation() {
    this.viewCtrl.dismiss(<Location>{
      lat: this.lat,
      lng: this.lng,
      zoom: this.zoom
    });
  }
}

Basically, what this component does is refreshing the current geo-location at a specific refresh rate. Note that in order to fetch the geo-location we use Geolocation's API, but behind the scene it uses `cordova-plugin-geolocation. The sendLocation method dismisses the view and returns the calculated geo-location. Now let's add the component's corresponding view:

12.14 Added location message template src/pages/messages/location-message.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<ion-header>
  <ion-toolbar color="whatsapp">
    <ion-title>Send Location</ion-title>
 
    <ion-buttons end>
      <button ion-button class="dismiss-button" (click)="viewCtrl.dismiss()"><ion-icon name="close"></ion-icon></button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>
 
<ion-content class="location-message-content">
  <ion-list>
    <agm-map [latitude]="lat" [longitude]="lng" [zoom]="zoom">
      <agm-marker [latitude]="lat" [longitude]="lng"></agm-marker>
    </agm-map>
    <ion-item (click)="sendLocation()">
      <ion-icon name="compass" item-left></ion-icon>
      <h2>Send your current location</h2>
      <p *ngIf="accuracy !== -1">Accurate to {{accuracy}} meters</p>
    </ion-item>
  </ion-list>
</ion-content>

The agm-map is the component which represents the map itself, and we provide it with lat, lng and zoom, so the map can be focused on the current geo-location. If you'll notice, we also used the agm-marker component with the same data-models, so the marker will be shown right in the center of the map.

Now we will add some CSS to make sure the map is visible:

12.15 Added location message stylesheet src/pages/messages/location-message.scss
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.location-message-content {
  .scroll-content {
    margin-top: 44px;
  }
 
  agm-map {
    padding: 0;
  }
 
  .sebm-google-map-container {
    height: 300px;
    margin-top: -15px;
  }
}

And we will import the component:

12.16 Import NewLocationMessageComponent src/app/app.module.ts
13
14
15
16
17
18
19
 
30
31
32
33
34
35
36
37
 
52
53
54
55
56
57
58
59
import { MessagesPage } from '../pages/messages/messages';
import { MessagesAttachmentsComponent } from '../pages/messages/messages-attachments';
import { MessagesOptionsComponent } from '../pages/messages/messages-options';
import { NewLocationMessageComponent } from '../pages/messages/location-message';
import { ProfilePage } from '../pages/profile/profile';
import { VerificationPage } from '../pages/verification/verification';
import { PhoneService } from '../services/phone';
...some lines skipped...
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent,
    MessagesAttachmentsComponent,
    NewLocationMessageComponent
  ],
  imports: [
    BrowserModule,
...some lines skipped...
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent,
    MessagesAttachmentsComponent,
    NewLocationMessageComponent
  ],
  providers: [
    StatusBar,

The component is ready. The only thing left to do would be revealing it. So we will add the appropriate handler in the MessagesAttachmentsComponent:

12.17 Implement the sendLocation message to display the new location modal src/pages/messages/messages-attachments.ts
1
2
3
4
5
6
7
 
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { Component } from '@angular/core';
import { ModalController, ViewController } from 'ionic-angular';
import { NewLocationMessageComponent } from './location-message';
import { MessageType } from 'api/models';
 
@Component({
  selector: 'messages-attachments',
...some lines skipped...
    private viewCtrl: ViewController,
    private modelCtrl: ModalController
  ) {}
 
  sendLocation(): void {
    const locationModal = this.modelCtrl.create(NewLocationMessageComponent);
    locationModal.onDidDismiss((location) => {
      if (!location) {
        this.viewCtrl.dismiss();
 
        return;
      }
 
      this.viewCtrl.dismiss({
        messageType: MessageType.LOCATION,
        selectedLocation: location
      });
    });
 
    locationModal.present();
  }
}

And we will bind it to its view:

12.18 Bind click event to sendLocation src/pages/messages/messages-attachments.html
10
11
12
13
14
15
16
      <div class="attachment-name">Camera</div>
    </button>
 
    <button ion-item class="attachment attachment-location" (click)="sendLocation()">
      <ion-icon name="locate" class="attachment-icon"></ion-icon>
      <div class="attachment-name">Location</div>
    </button>

Now we will implement a new method in the MessagesPage, called sendLocationMessage, which will create a string representation of the current geo-location and send it to the server:

12.19 Implement send location message src/pages/messages/messages.ts
1
2
3
4
5
6
 
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
 
231
232
233
234
235
236
237
238
239
240
241
242
import { Component, OnInit, OnDestroy, ElementRef } from '@angular/core';
import { NavParams, PopoverController } from 'ionic-angular';
import { Chat, Message, MessageType, Location } from 'api/models';
import { Messages } from 'api/collections';
import { MeteorObservable } from 'meteor-rxjs';
import * as moment from 'moment';
...some lines skipped...
    });
  }
 
  sendLocationMessage(location: Location): void {
    MeteorObservable.call('addMessage', MessageType.LOCATION,
      this.selectedChat._id,
      `${location.lat},${location.lng},${location.zoom}`
    ).zone().subscribe(() => {
      // Zero the input field
      this.message = '';
    });
  }
 
  showAttachments(): void {
    const popover = this.popoverCtrl.create(MessagesAttachmentsComponent, {
      chat: this.selectedChat
...some lines skipped...
    });
 
    popover.onDidDismiss((params) => {
      if (params) {
        if (params.messageType === MessageType.LOCATION) {
          const location = params.selectedLocation;
          this.sendLocationMessage(location);
        }
      }
    });
 
    popover.present();

This requires us to update the addMessage method in the server so it can support location typed messages:

12.20 Allow location message type on server side api/server/methods.ts
70
71
72
73
74
75
76
    if (!this.userId) throw new Meteor.Error('unauthorized',
      'User must be logged-in to create a new chat');
 
    check(type, Match.OneOf(String, [ MessageType.TEXT, MessageType.LOCATION ]));
    check(chatId, nonEmptyString);
    check(content, nonEmptyString);
 

Viewing Location Messages

The infrastructure is ready, but we can't yet see the message, therefore, we will need to add support for location messages in the MessagesPage view:

12.21 Implement location message view src/pages/messages/messages.html
19
20
21
22
23
24
25
26
27
28
29
30
      <div *ngFor="let message of day.messages" class="message-wrapper">
        <div [class]="'message message-' + message.ownership">
          <div *ngIf="message.type == 'text'" class="message-content message-content-text">{{message.content}}</div>
          <div *ngIf="message.type == 'location'" class="message-content message-content-text">
            <agm-map [zoom]="getLocation(message.content).zoom" [latitude]="getLocation(message.content).lat" [longitude]="getLocation(message.content).lng">
              <agm-marker [latitude]="getLocation(message.content).lat" [longitude]="getLocation(message.content).lng"></agm-marker>
            </agm-map>
          </div>
 
          <span class="message-timestamp">{{ message.createdAt | amDateFormat: 'HH:mm' }}</span>
        </div>
      </div>

These additions looks pretty similar to the LocationMessage since they are based on the same core components.

We will now add a method which can parse a string representation of the location into an actual JSON:

12.22 Implement getLocation for parsing the location src/pages/messages/messages.ts
241
242
243
244
245
246
247
248
249
250
251
252
253
254
 
    popover.present();
  }
 
  getLocation(locationString: string): Location {
    const splitted = locationString.split(',').map(Number);
 
    return <Location>{
      lat: splitted[0],
      lng: splitted[1],
      zoom: Math.min(splitted[2] || 0, 19)
    };
  }
}

And we will make some final adjustments for the view so the map can be presented properly:

12.23 Added map styles src/pages/messages/messages.scss
93
94
95
96
97
98
99
100
101
102
103
        content: " \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0";
        display: inline;
      }
 
      .sebm-google-map-container {
        height: 25vh;
        width: 35vh;
      }
    }
 
    .message-timestamp {