Skip to content

Commit

Permalink
feat(*): use GPS sensor data to set a location field (#2651)
Browse files Browse the repository at this point in the history
closes #1579
  • Loading branch information
Ayush8923 authored Nov 20, 2024
1 parent 16580a7 commit 0f4846c
Show file tree
Hide file tree
Showing 13 changed files with 266 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@
</div>

@if (!disabled) {
<app-address-search
(locationSelected)="updateFromAddressSearch($event)"
></app-address-search>
<div class="margin-bottom-regular flex-row gap-regular full-width">
<app-address-search
(locationSelected)="updateFromAddressSearch($event)"
class="full-width"
></app-address-search>
<app-address-gps-location
(locationSelected)="onGpsLocationSelected($event)"
></app-address-gps-location>
</div>
}
12 changes: 9 additions & 3 deletions src/app/features/location/address-edit/address-edit.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MatFormField, MatHint, MatLabel } from "@angular/material/form-field";
import { MatInput } from "@angular/material/input";
import { MatTooltip } from "@angular/material/tooltip";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { AddressGpsLocationComponent } from "../address-gps-location/address-gps-location.component";

/**
* Edit a GeoLocation / Address, including options to search via API and customize the string location being saved.
Expand All @@ -25,6 +26,7 @@ import { FaIconComponent } from "@fortawesome/angular-fontawesome";
MatTooltip,
MatIconButton,
FaIconComponent,
AddressGpsLocationComponent,
],
templateUrl: "./address-edit.component.html",
styleUrl: "./address-edit.component.scss",
Expand Down Expand Up @@ -114,8 +116,12 @@ export class AddressEditComponent {
geoLookup: value?.geoLookup,
});
}
}

function matchGeoResults(a: GeoResult, b: GeoResult) {
return a.lat === b.lat && a.lon === b.lon;
onGpsLocationSelected(geoResult: GeoResult) {
const newLocation: GeoLocation = {
locationString: geoResult.display_name,
geoLookup: geoResult,
};
this.updateFromAddressSearch(newLocation, true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<button
mat-icon-button
(click)="updateLocationFromGps()"
matTooltip="To accurately log your current address, please allow GPS access."
i18n-matTooltip
>
<fa-icon *ngIf="!gpsLoading" icon="location-crosshairs"></fa-icon>
<mat-spinner *ngIf="gpsLoading" diameter="24"></mat-spinner>
</button>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";

import { AddressGpsLocationComponent } from "./address-gps-location.component";
import { of } from "rxjs";
import { GeoService } from "../geo.service";
import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";

describe("AddressGpsLocationComponent", () => {
let component: AddressGpsLocationComponent;
let fixture: ComponentFixture<AddressGpsLocationComponent>;

let mockGeoService: jasmine.SpyObj<GeoService>;

beforeEach(async () => {
mockGeoService = jasmine.createSpyObj(["lookup", "reverseLookup"]);
mockGeoService.reverseLookup.and.returnValue(
of({
error: "Unable to geocode",
} as any),
);

await TestBed.configureTestingModule({
imports: [AddressGpsLocationComponent, FontAwesomeTestingModule],
providers: [{ provide: GeoService, useValue: mockGeoService }],
}).compileComponents();

fixture = TestBed.createComponent(AddressGpsLocationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Component, EventEmitter, Output } from "@angular/core";
import { Logging } from "app/core/logging/logging.service";
import { GpsService } from "../gps.service";
import { MatTooltip } from "@angular/material/tooltip";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
import { MatIconButton } from "@angular/material/button";
import { NgIf } from "@angular/common";
import { AlertService } from "app/core/alerts/alert.service";
import { GeoResult, GeoService } from "../geo.service";
import { firstValueFrom } from "rxjs";

@Component({
selector: "app-address-gps-location",
standalone: true,
imports: [
MatTooltip,
FaIconComponent,
MatProgressSpinnerModule,
NgIf,
MatTooltip,
MatIconButton,
],
templateUrl: "./address-gps-location.component.html",
styleUrl: "./address-gps-location.component.scss",
})
export class AddressGpsLocationComponent {
@Output() locationSelected = new EventEmitter<GeoResult>();

public gpsLoading = false;

constructor(
private gpsService: GpsService,
private alertService: AlertService,
private geoService: GeoService,
) {}

async updateLocationFromGps() {
this.gpsLoading = true;
try {
const location = await this.gpsService.getGpsLocationCoordinates();
if (location) {
const geoResult: GeoResult = await firstValueFrom(
this.geoService.reverseLookup(location),
);
this.locationSelected.emit(geoResult);
this.alertService.addInfo(
`Selected address based on GPS coordinate lookup as ${geoResult?.display_name}`,
);
}
} catch (error) {
Logging.error(error);
this.alertService.addInfo(
$localize`Failed to access device location. Please check if location permission is enabled in your device settings`,
);
} finally {
this.gpsLoading = false;
}
}
}
3 changes: 3 additions & 0 deletions src/app/features/location/coordinates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export interface Coordinates {
lat: number;
lon: number;

/** optional accuracy (e.g. from GPS location sensor) */
accuracy?: number;
}
15 changes: 12 additions & 3 deletions src/app/features/location/geo.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { Observable, of } from "rxjs";
import { Coordinates } from "./coordinates";
import { HttpClient } from "@angular/common/http";
import { ConfigService } from "../../core/config/config.service";
import { AnalyticsService } from "../../core/analytics/analytics.service";
import { environment } from "../../../environments/environment";
import { MAP_CONFIG_KEY, MapConfig } from "./map-config";
import { map } from "rxjs/operators";
import { catchError, map } from "rxjs/operators";

export interface GeoResult extends Coordinates {
display_name: string;
Expand Down Expand Up @@ -87,9 +87,15 @@ export class GeoService {
* @param coordinates of a place (`lat` and `lon`)
*/
reverseLookup(coordinates: Coordinates): Observable<GeoResult> {
const fallback: GeoResult = {
display_name: $localize`[selected coordinates: ${coordinates.lat} - ${coordinates.lon}]`,
...coordinates,
};

this.analytics.eventTrack("reverse_lookup_executed", {
category: "Map",
});

return this.http
.get<OpenStreetMapsSearchResult>(`${this.remoteUrl}/reverse`, {
params: {
Expand All @@ -98,7 +104,10 @@ export class GeoService {
lon: coordinates.lon,
},
})
.pipe(map((result) => this.reformatDisplayName(result)));
.pipe(
map((result) => this.reformatDisplayName(result)),
catchError(() => of(fallback)),
);
}
}

Expand Down
64 changes: 64 additions & 0 deletions src/app/features/location/gps.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { TestBed } from "@angular/core/testing";
import { GpsService } from "./gps.service";

function mockGeolocationPosition() {
return {
coords: {
latitude: 51.5074,
longitude: -0.1278,
accuracy: 5,
},
};
}

describe("GpsService", () => {
let service: GpsService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(GpsService);
});

it("should be created", () => {
expect(service).toBeTruthy();
});

it("should return coordinates if permission is granted", async () => {
spyOn(navigator.permissions, "query").and.returnValue(
Promise.resolve({ state: "granted" } as PermissionStatus),
);

spyOn(navigator.geolocation, "getCurrentPosition").and.callFake(
(successCallback) => {
successCallback(mockGeolocationPosition() as GeolocationPosition);
},
);

const coordinates = await service.getGpsLocationCoordinates();
expect(coordinates).toEqual({
lat: 51.5074,
lon: -0.1278,
accuracy: 5,
});
});

it("should throw an error if permission is denied", async () => {
spyOn(navigator.permissions, "query").and.returnValue(
Promise.resolve({ state: "denied" } as PermissionStatus),
);

await expectAsync(
service.getGpsLocationCoordinates(),
).toBeRejectedWithError(
"GPS permission denied or blocked. Please enable it in your device settings.",
);
});

it("should handle error when geolocation is not supported", async () => {
spyOnProperty(navigator, "geolocation").and.returnValue(undefined);

const coordinates = await service.getGpsLocationCoordinates();

expect(coordinates).toBeUndefined();
});
});
46 changes: 46 additions & 0 deletions src/app/features/location/gps.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Injectable } from "@angular/core";
import { Coordinates } from "./coordinates";

/**
* Access the device's GPS sensor to get the current location.
*/
@Injectable({
providedIn: "root",
})
export class GpsService {
constructor() {}

async getGpsLocationCoordinates(): Promise<Coordinates> {
if (!("geolocation" in navigator) || !navigator.geolocation) {
return;
}

const permissionStatus = await navigator.permissions.query({
name: "geolocation",
});

if (
permissionStatus.state !== "granted" &&
permissionStatus.state !== "prompt"
) {
throw new Error(
"GPS permission denied or blocked. Please enable it in your device settings.",
);
}

const position: GeolocationPosition = await new Promise(
(resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(position) => resolve(position),
(error) => reject(error),
);
},
);

return {
lat: position.coords.latitude,
lon: position.coords.longitude,
accuracy: position.coords.accuracy,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ describe("MapPopupComponent", () => {
component.markedLocations.subscribe((res) => (updatedLocations = res));

const mockedClick: Coordinates = { lat: 1, lon: 2 };

mockGeoService.reverseLookup.and.returnValue(
of({
lat: mockedClick.lat,
lon: mockedClick.lon,
display_name: `[selected on map: ${mockedClick.lat} - ${mockedClick.lon}]`,
}),
);

component.mapClicked(mockedClick);
tick();

Expand Down
22 changes: 3 additions & 19 deletions src/app/features/location/map-popup/map-popup.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ import {
} from "@angular/material/dialog";
import { Coordinates } from "../coordinates";
import { Entity } from "../../../core/entity/model/entity";
import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs";
import { BehaviorSubject, firstValueFrom, Observable, Subject } from "rxjs";
import { MapComponent } from "../map/map.component";
import { AsyncPipe } from "@angular/common";
import { MatButtonModule } from "@angular/material/button";
import { LocationProperties } from "../map/map-properties-popup/map-properties-popup.component";
import { GeoResult, GeoService } from "../geo.service";
import { catchError, map } from "rxjs/operators";
import { AddressEditComponent } from "../address-edit/address-edit.component";
import { GeoLocation } from "../location.datatype";

Expand Down Expand Up @@ -89,33 +88,18 @@ export class MapPopupComponent {
}

async mapClicked(newCoordinates: Coordinates) {
if (this.data.disabled) {
if (this.data.disabled || !newCoordinates) {
return;
}
const geoResult: GeoResult = await firstValueFrom(
this.lookupCoordinates(newCoordinates),
this.geoService.reverseLookup(newCoordinates),
);
this.updateLocation({
geoLookup: geoResult,
locationString: geoResult?.display_name,
});
}

private lookupCoordinates(coords: Coordinates) {
if (!coords) {
return undefined;
}

const fallback: GeoResult = {
display_name: $localize`[selected on map: ${coords.lat} - ${coords.lon}]`,
...coords,
};
return this.geoService.reverseLookup(coords).pipe(
map((res) => (res["error"] ? fallback : res)),
catchError(() => of(fallback)),
);
}

updateLocation(event: GeoLocation) {
this.selectedLocation = event;
this.markedLocations.next(event?.geoLookup ? [event?.geoLookup] : []);
Expand Down
7 changes: 7 additions & 0 deletions src/styles/globals/_flex-classes.scss
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@
justify-content: space-between;
}

/**
* aligns the items (i.e. places the items on the non main axis)
*/
.align-start {
align-items: start;
}

/**
* aligns the items (i.e. places the items on the non main axis)
*/
Expand Down

0 comments on commit 0f4846c

Please sign in to comment.