From 0f4846c13be66c499987690cf595928470bd68a0 Mon Sep 17 00:00:00 2001 From: Ayush Date: Wed, 20 Nov 2024 16:20:26 +0530 Subject: [PATCH] feat(*): use GPS sensor data to set a location field (#2651) closes #1579 --- .../address-edit/address-edit.component.html | 12 +++- .../address-edit/address-edit.component.ts | 12 +++- .../address-gps-location.component.html | 9 +++ .../address-gps-location.component.scss | 0 .../address-gps-location.component.spec.ts | 35 ++++++++++ .../address-gps-location.component.ts | 60 +++++++++++++++++ src/app/features/location/coordinates.ts | 3 + src/app/features/location/geo.service.ts | 15 ++++- src/app/features/location/gps.service.spec.ts | 64 +++++++++++++++++++ src/app/features/location/gps.service.ts | 46 +++++++++++++ .../map-popup/map-popup.component.spec.ts | 9 +++ .../location/map-popup/map-popup.component.ts | 22 +------ src/styles/globals/_flex-classes.scss | 7 ++ 13 files changed, 266 insertions(+), 28 deletions(-) create mode 100644 src/app/features/location/address-gps-location/address-gps-location.component.html create mode 100644 src/app/features/location/address-gps-location/address-gps-location.component.scss create mode 100644 src/app/features/location/address-gps-location/address-gps-location.component.spec.ts create mode 100644 src/app/features/location/address-gps-location/address-gps-location.component.ts create mode 100644 src/app/features/location/gps.service.spec.ts create mode 100644 src/app/features/location/gps.service.ts diff --git a/src/app/features/location/address-edit/address-edit.component.html b/src/app/features/location/address-edit/address-edit.component.html index 7880817835..fab9f5564e 100644 --- a/src/app/features/location/address-edit/address-edit.component.html +++ b/src/app/features/location/address-edit/address-edit.component.html @@ -65,7 +65,13 @@ @if (!disabled) { - +
+ + +
} diff --git a/src/app/features/location/address-edit/address-edit.component.ts b/src/app/features/location/address-edit/address-edit.component.ts index 8882301562..4e0a539e79 100644 --- a/src/app/features/location/address-edit/address-edit.component.ts +++ b/src/app/features/location/address-edit/address-edit.component.ts @@ -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. @@ -25,6 +26,7 @@ import { FaIconComponent } from "@fortawesome/angular-fontawesome"; MatTooltip, MatIconButton, FaIconComponent, + AddressGpsLocationComponent, ], templateUrl: "./address-edit.component.html", styleUrl: "./address-edit.component.scss", @@ -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); + } } diff --git a/src/app/features/location/address-gps-location/address-gps-location.component.html b/src/app/features/location/address-gps-location/address-gps-location.component.html new file mode 100644 index 0000000000..385cd15e4e --- /dev/null +++ b/src/app/features/location/address-gps-location/address-gps-location.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/features/location/address-gps-location/address-gps-location.component.scss b/src/app/features/location/address-gps-location/address-gps-location.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/features/location/address-gps-location/address-gps-location.component.spec.ts b/src/app/features/location/address-gps-location/address-gps-location.component.spec.ts new file mode 100644 index 0000000000..b5e4037a2a --- /dev/null +++ b/src/app/features/location/address-gps-location/address-gps-location.component.spec.ts @@ -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; + + let mockGeoService: jasmine.SpyObj; + + 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(); + }); +}); diff --git a/src/app/features/location/address-gps-location/address-gps-location.component.ts b/src/app/features/location/address-gps-location/address-gps-location.component.ts new file mode 100644 index 0000000000..9398c153de --- /dev/null +++ b/src/app/features/location/address-gps-location/address-gps-location.component.ts @@ -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(); + + 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; + } + } +} diff --git a/src/app/features/location/coordinates.ts b/src/app/features/location/coordinates.ts index 3d0fc793b2..ed568eabae 100644 --- a/src/app/features/location/coordinates.ts +++ b/src/app/features/location/coordinates.ts @@ -1,4 +1,7 @@ export interface Coordinates { lat: number; lon: number; + + /** optional accuracy (e.g. from GPS location sensor) */ + accuracy?: number; } diff --git a/src/app/features/location/geo.service.ts b/src/app/features/location/geo.service.ts index 197bc8cd42..d097ce03ef 100644 --- a/src/app/features/location/geo.service.ts +++ b/src/app/features/location/geo.service.ts @@ -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; @@ -87,9 +87,15 @@ export class GeoService { * @param coordinates of a place (`lat` and `lon`) */ reverseLookup(coordinates: Coordinates): Observable { + 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(`${this.remoteUrl}/reverse`, { params: { @@ -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)), + ); } } diff --git a/src/app/features/location/gps.service.spec.ts b/src/app/features/location/gps.service.spec.ts new file mode 100644 index 0000000000..d4c00befc0 --- /dev/null +++ b/src/app/features/location/gps.service.spec.ts @@ -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(); + }); +}); diff --git a/src/app/features/location/gps.service.ts b/src/app/features/location/gps.service.ts new file mode 100644 index 0000000000..cd76fd6125 --- /dev/null +++ b/src/app/features/location/gps.service.ts @@ -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 { + 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, + }; + } +} diff --git a/src/app/features/location/map-popup/map-popup.component.spec.ts b/src/app/features/location/map-popup/map-popup.component.spec.ts index 8056f64998..a00c04b5e7 100644 --- a/src/app/features/location/map-popup/map-popup.component.spec.ts +++ b/src/app/features/location/map-popup/map-popup.component.spec.ts @@ -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(); diff --git a/src/app/features/location/map-popup/map-popup.component.ts b/src/app/features/location/map-popup/map-popup.component.ts index 593664aae8..17aa1ff347 100644 --- a/src/app/features/location/map-popup/map-popup.component.ts +++ b/src/app/features/location/map-popup/map-popup.component.ts @@ -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"; @@ -89,11 +88,11 @@ 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, @@ -101,21 +100,6 @@ export class MapPopupComponent { }); } - 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] : []); diff --git a/src/styles/globals/_flex-classes.scss b/src/styles/globals/_flex-classes.scss index 8aa5f44fb0..bc2e59147e 100644 --- a/src/styles/globals/_flex-classes.scss +++ b/src/styles/globals/_flex-classes.scss @@ -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) */