Recording Geo location data with PhoneGap and Angular (Part 3).

Published on May 05, 2014

Other articles in the Ionic series.

  1. Building an iOS app with PhoneGap, Angular.js and the ionic framework (Part 1).
  2. Building a basic iOS UI using Angular and Ionic (Part 2).
  3. Refactoring the js code structure PhoneGap and Angular (Part 4).
  4. Uploading files with PhoneGap and Angular (Part 5).
  5. A few different ways to improve user feedback in our Ionic application (Part 6).
  6. Displaying current and max speed with PhoneGap and Ionic (Part 7).
  7. Deleting files with PhoneGap (Part 8).
  8. Calculating distance and speed with the GeoLocation API - PhoneGap (Part 9).

Code for this series is available on [Github](https://github.com/hgarcia/dynamic-sports)

The main activity of our app will be to start and stop recording data when we perform our activities.

The simplest UI

During development I like to try to do most of the styling using Firefox (or Chrome) since is faster that deploying to the emulator after each change and I can also use the development tools of the browser.

Ionic comes with a web server for this purpose.

In the command line go to the root of the web application (inside the WWW folder of the solution) and run the following command



	python -m SimpleHTTPServer 8000

If you are using port 8000 for something else you can change that.
We will be using sass so we need to re-compile the sass files after each changes, so we can open another command window and run the gulp watch command.



	gulp watch sass

We are not ready to start working on the UI.
We will start with a simple button that should change between Start and Stop when clicked. We can modify the /templates/home.html to have a new button in the center of the screen.



	<ion-view title="New session">
	  <ion-content class="center-child" has-header="true" padding="true">
	    <button class="button button-block button-positive button-outline icon ion-play start-stop"></button>
	  </ion-content>
	</ion-view>

We need to add the styling for the button. We will create a new _home.scss file under the scss/app/ folder.



	.center-child {
	  .scroll {
	    left: 50%;
	    margin-left: -55px;
	    margin-top: -110px;
	    position: fixed;
	    top: 50%;
	    .button {
	      &.start-stop {
	        border-radius: 50px;
	        height: 100px;
	        margin: 0 auto;
	        position: relative;
	        width: 100px;
	      }
	      &.icon {
	        &:before {
	          font-size: 48px;
	          padding-left: 10px;
	        }
	      }
	    }
	  }
	}

Notice the style applied to the scroll class. That’s the trick we use to center the button vertically on the screen no matter what device we are using. For a detailed explanation on how and why this works check this article on the css tricks website

The difference with respect to the article is that we need to use a bigger margin-top to account for the status bar on top of the device.

iPhone 5 view

Adding the button vertically centered on the emulator (iphone 5)

A directive.

So far we have a play button, but we need to switch the button and convert it into a stop button after clicked.

There are a few approaches to that.
We can put that logic into the HomeController and modify the styles of the button (please don’t).
We could add a second button and use the ng-show and ng-hide directives to show the right one (not that bad).
Or we can encapsulate the behaviour in a directive. This will be a very simple directive for this specific case. We can create a toggle state button directive that could be more generic, but at this point I will be creating one specific for this use case.

Setting the project up for testing (redux).

In the first article of the series we setup karma for testing, but we did miss something.

Angular provides a set of mock methods and helper function to use on your tests. Those elements are distributed in the angular-mocks.js file. Ionic doesn’t include that file (at least in the version I’m using at the moment). So we will need to download the proper angular-mocks from the angular site .

The version of Ionic I’m using depends on angular 1.2.12 so we download the angular-mocks.js fie for that version of Angular.

You can check the version of angular you are using opening the angular.js file inside the www/lib/ionic/js/angular folder of your project

I will save the file inside the www/lib/ionic/js/angular folder and I will modify the karma.config.js file to include the new file.



	 // list of files / patterns to load in the browser
    files: [
      'lib/ionic/js/ionic.bundle.js',
      'lib/ionic/js/angular/angular-mocks.js',
      'js/*.js',
      '../tests/**/*_spec.js'
    ],

Notice that the order of the files in the files array is important!

Writing our directive.

We know that we need a method to handle the click event and that method will indicate if the button is on or off and also delegate to a handler given to him.

We write our tests first.



	/*global jasmine, describe, beforeEach, it, expect, module, inject */
	describe("PlayStopButtonController", function () {
	  "use strict";
	  var scope, controller, directive;

	  beforeEach(module("dynamic-sports.directives"));

	  beforeEach(inject(function ($rootScope, $controller, playStopButtonDirective) {
	    scope = $rootScope.$new();
	    controller = $controller;
	    directive = playStopButtonDirective[0];
	    controller("PlayStopButtonController", {$scope: scope});
	  }));

	  describe("directive creation", function () {

	    it("should use the PlayStopButtonController controller", function () {
	      expect(directive.controller).toEqual("PlayStopButtonController");
	    });

	    it("should use the BirthDate template", function () {
	      expect(directive.templateUrl).toEqual("/templates/directives/play-stop-button.html");
	    });

	    it("should be an element", function () {
	      expect(directive.restrict).toEqual("E");
	    });

	    it("should replace content", function () {
	      expect(directive.replace).toBeFalsy();
	    });
	  });

	  describe("click()", function () {

	    beforeEach(function () {
	      scope.isOn = false;
	      scope.clickHandler = jasmine.createSpy();
	      scope.click();
	    });

	    it("should set 'isOn' to true", function () {
	      expect(scope.isOn).toBeTruthy();
	    });

	    it("should call the given clickHandler with the value of 'isOn'", function () {
	      expect(scope.clickHandler).toHaveBeenCalledWith(true);
	    });

	    it("should not throw if a clickHandler is not provided", function () {
	      scope.clickHandler = undefined;
	      expect(scope.click).not.toThrow();
	    });
	  });
	});

And we go ahead and get on with the implementation



	angular.module('dynamic-sports.directives')
	  .controller('PlayStopButtonController', ['$scope', function ($scope) {
	    'use strict';
	    $scope.isOn = false;
	    $scope.click = function () {
	      $scope.isOn = !$scope.isOn;
	      if ($scope.clickHandler) {
	        $scope.clickHandler($scope.isOn);
	      }
	    };
	  }])
	  .directive("playStopButton", function () {
	    "use strict";
	    return {
	      templateUrl: "/templates/directives/play-stop-button.html",
	      scope: {clickHandler: "="},
	      controller: "PlayStopButtonController",
	      replace: false,
	      restrict: "E"
	    };
	  });

I’m restricting the directive to be an element directive and I’m setting replace to false so I don’t have to provide a root element in the template.

Our template is very simple.



	<button class="button button-block icon start-stop" ng-class="{'false': 'button-positive button-outline ion-play', 'true': 'button-assertive ion-pause'}[isOn]" ng-click="click()"></button>

We will have to adjust our CSS for the pause button as follow.



	&.icon {
		&:before {
		  font-size: 48px;
		}
	}
	&.ion-play {
		&:before {
		  padding-left: 10px;
		}
	}
	&.ion-pause {
		&:before {
		  padding-left: 2px;
		}
	}

The controller.

Now that we have the button in place we should hook it up to start recording the location data. We will do that via the HomeController.

We start with the following tests.



	/*global describe: true, beforeEach: true, it: true, expect: true, module: true, inject: true, spyOn */
	describe("HomeCtrl", function () {
	  "use strict";
	  var scope, geoLocationService;

	  beforeEach(module("dynamic-sports"));

	  beforeEach(inject(function ($rootScope, $controller, _geoLocationService_) {
	    scope = $rootScope.$new();
	    geoLocationService = _geoLocationService_;
	    $controller("HomeCtrl", {$scope: scope, geoLocationService: geoLocationService});
	  }));

	  describe("#recording()", function () {

	    beforeEach(function () {
	      spyOn(geoLocationService, "start");
	      spyOn(geoLocationService, "stop");
	    });

	    it("should start recording if 'on' === true", function () {
	      scope.recording(true);
	      expect(geoLocationService.start).toHaveBeenCalled();
	    });

	    it("should stop recording if 'on' === false", function () {
	      scope.recording(false);
	      expect(geoLocationService.stop).toHaveBeenCalled();
	    });
	  });
	});

The implementation is super simple at the moment, notice the geoLocationService that we are injecting in the controller. At the moment is just an empty shell that we are spying on in the tests.



	/* globals angular, console */
	angular.module('dynamic-sports.controllers')
  	  .controller('HomeCtrl', ['$scope', 'geoLocationService',
      function ($scope, geoLocationService) {
      'use strict';

	  $scope.recording = function (on) {
	    if (on) {
	      geoLocationService.start();
	    } else {
	      geoLocationService.stop();
	    }
	  };
  	}]);

Geo location service.

Time to implement the geolocation service that does the actual work! We could probably consume this from inside the controller but please don’t do that or your controllers are going to end up big and bloated.

We need to install the cordova geolocation plugin



	cordova plugin add https://git-wip-us.apache.org/repos/asf/cordova-plugin-geolocation.git
	// or
	cordova plugin add org.apache.cordova.geolocation

Our service needs to expose two methods. start and Stop.

The start method will take two callbacks. We start writing some tests.



	/* global describe, beforeEach, it, module, inject, navigator: true */
	describe("Geo Location services", function () {
	  "use strict";
	  var scope, service;

	  beforeEach(module("dynamic-sports.services"));

	  beforeEach(inject(function ($rootScope, _geoLocationService_) {
	    scope = $rootScope.$new();
	    navigator = {geolocation: {watchPosition: jasmine.createSpy().andReturn("12345"), clearWatch: jasmine.createSpy()}};
	    service = _geoLocationService_;
	  }));

	  describe("start(successCb, errorCb)", function () {

	    it("should start watching the position", function () {
	      var success = function () {};
	      var error = function () {};
	      service.start(success, error);
	      expect(navigator.geolocation.watchPosition).toHaveBeenCalledWith(success, error);
	    });
	  });

	  describe("stop()", function () {

	    it("should not call clearWatch if we are not watching", function () {
	      service.stop();
	      expect(navigator.geolocation.clearWatch).not.toHaveBeenCalled();
	    });

	    it("should call clearWatch with the watchId", function () {
	      service.start();
	      service.stop();
	      expect(navigator.geolocation.clearWatch).toHaveBeenCalledWith("12345");
	    });
	  });
	});

We are creating a bunch of spies to mock out the geolocation Cordova/PhoneGap API‘s.

The implementation is super simple for now.



	/* globals angular */
	angular.module('dynamic-sports.services')
	.factory('geoLocationService', function () {
		'use strict';
		var watchId;
		return {
		  start: function (success, error) {
		    watchId = navigator.geolocation.watchPosition(success, error);
		  },
		  stop: function () {
		    if (watchId) {
		       navigator.geolocation.clearWatch(watchId);
		    }
		  }
		};
	});

Our file service.

Right now we only need to save data into a file. We install the file service plug-in.



	cordova plugin add https://git-wip-us.apache.org/repos/asf/cordova-plugin-file.git
	// or
	cordovae plugin add org.apache.cordova.file

We are going to create a simple API for the service with two methods save() and open()

We will drive our code with the following tests.



/* global describe, beforeEach, it, module, inject, navigator */
	describe("File services", function () {
	  "use strict";
	  var scope, service, openSuccess, openError;

	  beforeEach(module("dynamic-sports.services"));

	  beforeEach(inject(function ($rootScope, _fileService_) {
	    scope = $rootScope.$new();
	    service = _fileService_;
	    openSuccess = jasmine.createSpy();
	    openError = jasmine.createSpy();
	  }));

	  describe("open(fileName, successCb, errorCb)", function () {

	    it("should use the file service", function () {
	      spyOn(window, "requestFileSystem");
	      service.open("file", openSuccess, openError);
	      expect(window.requestFileSystem).toHaveBeenCalled();
	    });

	    it("should return the file content on success", function () {
	      service.open("file", openSuccess, openError);
	      window.OnRequestFileSystemSuccess().OnGetFileSuccess().OnFileEntrySuccess("file content");
	      expect(openSuccess).toHaveBeenCalledWith("file content");
	    });

	    it("should call the errorCb if requestFileSystem fails", function () {
	      service.open("file", openSuccess, openError);
	      window.OnRequestFileSystemError();
	      expect(openError).toHaveBeenCalled();
	    });

	    it("should call the errorCb if can't get the file", function () {
	      service.open("file", openSuccess, openError);
	      window.OnRequestFileSystemSuccess().OnGetFileError();
	      expect(openError).toHaveBeenCalled();
	    });

	    it("should call the errorCb if can't read the file", function () {
	      service.open("file", openSuccess, openError);
	      window.OnRequestFileSystemSuccess().OnGetFileSuccess().OnFileEntryError();
	      expect(openError).toHaveBeenCalled();
	    });
	  });

	  describe("save(fileName, data, successCb, errorCb)", function () {

	    it("should use the file service", function () {
	      spyOn(window, "requestFileSystem");
	      service.save("file", {}, openSuccess, openError);
	      expect(window.requestFileSystem).toHaveBeenCalled();
	    });

	    describe("content serialization", function () {

	      beforeEach(function () {
	        spyOn(writer, 'write');
	      });

	      it("should serialize the content and add to the file", function () {
	        service.save("file", {data: "some-data"}, openSuccess, openError);
	        window.OnRequestFileSystemSuccess().OnGetFileSuccess().OnWriteSuccess();
	        expect(writer.write).toHaveBeenCalledWith('{"data":"some-data"}');
	      });

	      it("should add a string", function () {
	        service.save("file", "data", openSuccess, openError);
	        window.OnRequestFileSystemSuccess().OnGetFileSuccess().OnWriteSuccess();
	        expect(writer.write).toHaveBeenCalledWith('"data"');
	      });

	      it("should add an integer", function () {
	        service.save("file", 123, openSuccess, openError);
	        window.OnRequestFileSystemSuccess().OnGetFileSuccess().OnWriteSuccess();
	        expect(writer.write).toHaveBeenCalledWith('123');
	      });
	    });
	  });
	});

Resulting in the following implementation.



	/* globals angular */
	angular.module('dynamic-sports.services')
	  .factory('fileService', function () {
	  'use strict';

	    function writeToFile(data, successCb) {
	      return function (writer) {
	        writer.onwriteend = function(evt) {
	          if (successCb) {
	            successCb();
	          }
	        };
	        writer.seek(writer.length);
	        writer.write(JSON.stringify(data));
	      };
	    }

	    function gotFileEntry(data, successCb, errorCb) {
	      return function(fileEntry) {
	        fileEntry.createWriter(writeToFile(data, successCb), errorCb);
	      };
	    }

	    function write(fileName, data, successCb, errorCb) {
	      return function (fileSystem) {
	        fileSystem.root.getFile(fileName, {create: true, exclusive: false}, gotFileEntry(data, successCb, errorCb), errorCb);
	      };
	    }

	    function fileContents(successCb) {
	      return function (file) {
	        var reader = new FileReader();
	        reader.onloadend = function(evt) {
	          successCb(evt.target.result);
	        };
	        reader.readAsText(file);
	      };
	    }

	    function readFile(successCb, errorCb) {
	      return function (fileEntry) {
	        fileEntry.file(fileContents(successCb), errorCb);
	      };
	    }

	    function read(fileName, successCb, errorCb) {
	      return function (fileSystem) {
	        fileSystem.root.getFile(fileName, null, readFile(successCb, errorCb), errorCb);
	      };
	    }

	    return {
	      save: function (fileName, data, successCb, errorCb) {
	        window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, write(fileName, data, successCb, errorCb), errorCb);
	      },
	      open: function (fileName, successCb, errorCb) {
	        window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, read(fileName, successCb, errorCb), errorCb);
	      }
	    };
	  });

We are introducing a cordova_mocks.js file in the test folder that’s mocking all the Cordova services (and plug-ins) for us to be able to easily test our service.

We added the file to the file section of the karma.config.js file.



	// list of files / patterns to load in the browser
    files: [
      'lib/ionic/js/ionic.bundle.js',
      'lib/ionic/js/angular/angular-mocks.js',
      '../tests/cordova_mocks.js',
      '../js/*.js',
      '../js/**/*.js',
      '../tests/**/*_spec.js'
    ],

This mock file will evolve with the application.



	//Geo location

	navigator = {geolocation: {watchPosition: jasmine.createSpy().andReturn("12345"), clearWatch: jasmine.createSpy()}};

	// File
	LocalFileSystem = {PERSISTENT: 1};

	var requestFileSystemSuccessCb, requestFileSystemErrorCb, getFileSuccesCb, getFileErrorCb, fileSuccesCb, fileErrorCb, writerCb;
	var fileSystem = {
	  root: {
	    getFile: function (fileName, options, successCb, errorCb) {
	      getFileSuccesCb = successCb;
	      getFileErrorCb = errorCb;
	    }
	  }
	};

	var writer = {
	  seek: function () {},
	  length: 0,
	  write: function (data) {
	    this.onwriteend();
	  }
	};

	var fileEntry = {
	  file: function (successCb, errorCb) {
	    fileSuccesCb = successCb;
	    fileErrorCb = errorCb;
	  },
	  createWriter: function (cb) {
	    writerCb = cb;
	  }
	};

	function FileReader () {
	  this.readAsText = function (file) {
	    if (this.onloadend) {
	      this.onloadend({target: {result: file}});
	    }
	  };
	}

	window.requestFileSystem = function (cons1, cons2, successCb, errorCb) {
	  requestFileSystemSuccessCb = successCb;
	  requestFileSystemErrorCb = errorCb;
	};

	window.OnRequestFileSystemError = function () {
	  if (requestFileSystemErrorCb) {
	    requestFileSystemErrorCb();
	  }
	};

	window.OnRequestFileSystemSuccess = function () {
	  if (requestFileSystemSuccessCb) {
	    requestFileSystemSuccessCb(fileSystem);
	  }
	  return {
	    OnGetFileError: function () {
	      if (getFileErrorCb) {
	        getFileErrorCb();
	      }
	    },
	    OnGetFileSuccess: function () {
	      if (getFileSuccesCb) {
	        getFileSuccesCb(fileEntry);
	      }
	      return {
	        OnWriteSuccess: function () {
	          writerCb(writer);
	        },
	        OnFileEntryError: function () {
	          if (fileErrorCb) {
	            fileErrorCb();
	          }
	        },
	        OnFileEntrySuccess: function (content) {
	          if (fileSuccesCb) {
	            fileSuccesCb(content);
	          }
	        }
	      };
	    }
	  };
	};

Hooking everything up.

We need to add a new method in the HomeCtrl to hook up the click event for the main button and these two new services. You may noticed that for now we are just using an alert to display the data and some errors up to the user. We will be changing that later on.



	/* globals angular, console */
	angular.module('dynamic-sports.controllers')
	  .controller('HomeCtrl', ['$scope', 'geoLocationService', 'fileService',
	    function ($scope, geoLocationService, fileService) {
	    'use strict';
	    var fileName;

	    function onChange(newPosition) {
	      var data = newPosition.coords;
	      data.timestamp = newPosition.timestamp;
	      fileService.save(fileName, data, function () {}, function (error) {});
	    }

	    function onChangeError(error) {
	      alert("Error: " + error);
	    }

	    $scope.recording = function (on) {
	      if (on) {
	        fileName = geoLocationService.start(onChange, onChangeError);
	      } else {
	        geoLocationService.stop();
	        fileService.open(fileName, function (result) { alert(result); }, function (error) {alert("Err:" + error); });
	      }
	    };

	  }]);

We drove the implementation with the following test.



	/*global describe: true, beforeEach: true, it: true, expect: true, module: true, inject: true, spyOn */
	describe("HomeCtrl", function () {
	  "use strict";
	  var scope, geoLocationService, fileService;

	  beforeEach(module("dynamic-sports"));

	  beforeEach(inject(function ($rootScope, $controller, _geoLocationService_, _fileService_) {
	    scope = $rootScope.$new();
	    geoLocationService = _geoLocationService_;
	    fileService = _fileService_;
	    $controller("HomeCtrl", {$scope: scope, geoLocationService: geoLocationService, fileService: fileService});
	  }));

	  describe("#recording() start", function () {

	    beforeEach(function () {
	      spyOn(geoLocationService, "start").andReturn("123456");
	      spyOn(fileService, "save");
	      scope.recording(true);
	    });

	    it("should start recording", function () {
	      expect(geoLocationService.start).toHaveBeenCalled();
	    });

	    it("should call the fileService.save method", function () {
	      var payload = {coords: {}, timestamp: "time-stamp-here"};
	      geoLocationService.start.mostRecentCall.args[0](payload);
	      expect(fileService.save).toHaveBeenCalled();
	    });
	  });

	  describe("#recording() stop", function () {

	    beforeEach(function () {
	      spyOn(geoLocationService, "stop");
	      scope.recording(false);
	    });

	    it("should stop recording if 'on' === false", function () {
	      expect(geoLocationService.stop).toHaveBeenCalled();
	    });
	  });
	});

Next steps.

On part 4, we will do a quick refactoring of the js code to improve maintainability.