-
Latitude: {{ coords.latitude }}
-
Longitude: {{ coords.longitude }}
-
Located At: {{ new Date(locatedAt).toLocaleString() }}
-
Error: {{ error.message }}
-
-
-
-
-
+
+
{{ $t("location_permission.title") }}
+
+ {{ $t("location_permission.denied") }}
+
+
+ {{ $t("location_permission.unavailable") }}
+
+
+ {{ $t("location_permission.timeout") }}
+
+
{{ $t("location_permission.explanation") }}
+
diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json
index 4582dc5f..567cdaa2 100644
--- a/src/i18n/generated/da-v2.json
+++ b/src/i18n/generated/da-v2.json
@@ -1145,6 +1145,19 @@
}
},
"templates": {
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Brug din placering",
+ "explanation": "Brug din placering til at finde den nærmeste Truck Wash-afdeling.",
+ "use_current": "Brug min placering",
+ "checking": "Kontrollerer placering...",
+ "denied": "Placering er slået fra for Truck Wash. Vælg en afdeling manuelt, eller giv adgang i iOS-indstillingerne.",
+ "unavailable": "Din placering er ikke tilgængelig. Du kan stadig vælge en afdeling manuelt.",
+ "timeout": "Det tog for lang tid at finde din placering. Prøv igen, eller vælg en afdeling manuelt."
+ }
+ }
+ },
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktoplysninger @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json
index 334f03ac..bc0feff7 100644
--- a/src/i18n/generated/de-v2.json
+++ b/src/i18n/generated/de-v2.json
@@ -1255,6 +1255,19 @@
}
},
"templates": {
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Standort verwenden",
+ "explanation": "Verwenden Sie Ihren Standort, um die nächste Truck Wash-Niederlassung zu finden.",
+ "use_current": "Meinen Standort verwenden",
+ "checking": "Standort wird geprüft...",
+ "denied": "Der Standortzugriff ist für Truck Wash deaktiviert. Wählen Sie eine Niederlassung manuell oder erlauben Sie den Zugriff in den iOS-Einstellungen.",
+ "unavailable": "Ihr Standort ist nicht verfügbar. Sie können weiterhin eine Niederlassung manuell auswählen.",
+ "timeout": "Die Standortbestimmung hat zu lange gedauert. Versuchen Sie es erneut oder wählen Sie eine Niederlassung manuell."
+ }
+ }
+ },
"support": {
"title": "@:{'words.generated.support'}",
"meta_description": "@:{'words.generated.support'}- @:{'words.generated.und'} Kontaktinformationen @:{'words.generated.fuer'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json
index c543e500..92363ce7 100644
--- a/src/i18n/generated/en-v2.json
+++ b/src/i18n/generated/en-v2.json
@@ -976,6 +976,19 @@
}
},
"templates": {
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Use your location",
+ "explanation": "Use your location to find the nearest Truck Wash department.",
+ "use_current": "Use my location",
+ "checking": "Checking location...",
+ "denied": "Location is disabled for Truck Wash. Select a department manually or allow access in iOS Settings.",
+ "unavailable": "Your location is unavailable. You can still select a department manually.",
+ "timeout": "Finding your location took too long. Try again or select a department manually."
+ }
+ }
+ },
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.and'} @:{'words.generated.contact'} @:{'words.generated.information'} @:{'words.generated.for'} @.capitalize:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json
index 25046318..28f11160 100644
--- a/src/i18n/generated/global-v2.json
+++ b/src/i18n/generated/global-v2.json
@@ -3693,6 +3693,15 @@
"succeeded": "@:{'templates.generated.compat.invoicing.progress.succeeded'}"
}
},
+ "location_permission": {
+ "title": "@:{'templates.generated.compat.location_permission.title'}",
+ "explanation": "@:{'templates.generated.compat.location_permission.explanation'}",
+ "use_current": "@:{'templates.generated.compat.location_permission.use_current'}",
+ "checking": "@:{'templates.generated.compat.location_permission.checking'}",
+ "denied": "@:{'templates.generated.compat.location_permission.denied'}",
+ "unavailable": "@:{'templates.generated.compat.location_permission.unavailable'}",
+ "timeout": "@:{'templates.generated.compat.location_permission.timeout'}"
+ },
"login_qr": {
"camera_error": "@:{'templates.generated.compat.login_qr.camera_error'}",
"login_with_password_instead": "@:{'templates.generated.compat.login_qr.login_with_password_instead'}",
diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json
index 1558cb9d..73bb9622 100644
--- a/src/i18n/generated/no-v2.json
+++ b/src/i18n/generated/no-v2.json
@@ -1258,6 +1258,19 @@
}
},
"templates": {
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Bruk posisjonen din",
+ "explanation": "Bruk posisjonen din til å finne nærmeste Truck Wash-avdeling.",
+ "use_current": "Bruk posisjonen min",
+ "checking": "Kontrollerer posisjon...",
+ "denied": "Posisjon er slått av for Truck Wash. Velg en avdeling manuelt, eller gi tilgang i iOS-innstillingene.",
+ "unavailable": "Posisjonen din er ikke tilgjengelig. Du kan fortsatt velge en avdeling manuelt.",
+ "timeout": "Det tok for lang tid å finne posisjonen din. Prøv igjen, eller velg en avdeling manuelt."
+ }
+ }
+ },
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktinformasjon @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json
index eeb011cf..c5e8c377 100644
--- a/src/i18n/generated/sv-v2.json
+++ b/src/i18n/generated/sv-v2.json
@@ -1308,6 +1308,19 @@
}
},
"templates": {
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Använd din plats",
+ "explanation": "Använd din plats för att hitta närmaste Truck Wash-avdelning.",
+ "use_current": "Använd min plats",
+ "checking": "Kontrollerar plats...",
+ "denied": "Platsåtkomst är avstängd för Truck Wash. Välj en avdelning manuellt eller tillåt åtkomst i iOS-inställningarna.",
+ "unavailable": "Din plats är inte tillgänglig. Du kan fortfarande välja en avdelning manuellt.",
+ "timeout": "Det tog för lång tid att hitta din plats. Försök igen eller välj en avdelning manuellt."
+ }
+ }
+ },
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.och'} kontaktinformation @:{'words.generated.for_2'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
diff --git a/src/i18n/source/da/phrases/compat/location_permission/index.json b/src/i18n/source/da/phrases/compat/location_permission/index.json
new file mode 100644
index 00000000..11e09d7b
--- /dev/null
+++ b/src/i18n/source/da/phrases/compat/location_permission/index.json
@@ -0,0 +1,15 @@
+{
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Brug din placering",
+ "explanation": "Brug din placering til at finde den nærmeste Truck Wash-afdeling.",
+ "use_current": "Brug min placering",
+ "checking": "Kontrollerer placering...",
+ "denied": "Placering er slået fra for Truck Wash. Vælg en afdeling manuelt, eller giv adgang i iOS-indstillingerne.",
+ "unavailable": "Din placering er ikke tilgængelig. Du kan stadig vælge en afdeling manuelt.",
+ "timeout": "Det tog for lang tid at finde din placering. Prøv igen, eller vælg en afdeling manuelt."
+ }
+ }
+ }
+}
diff --git a/src/i18n/source/de/phrases/compat/location_permission/index.json b/src/i18n/source/de/phrases/compat/location_permission/index.json
new file mode 100644
index 00000000..0e0eeaf6
--- /dev/null
+++ b/src/i18n/source/de/phrases/compat/location_permission/index.json
@@ -0,0 +1,15 @@
+{
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Standort verwenden",
+ "explanation": "Verwenden Sie Ihren Standort, um die nächste Truck Wash-Niederlassung zu finden.",
+ "use_current": "Meinen Standort verwenden",
+ "checking": "Standort wird geprüft...",
+ "denied": "Der Standortzugriff ist für Truck Wash deaktiviert. Wählen Sie eine Niederlassung manuell oder erlauben Sie den Zugriff in den iOS-Einstellungen.",
+ "unavailable": "Ihr Standort ist nicht verfügbar. Sie können weiterhin eine Niederlassung manuell auswählen.",
+ "timeout": "Die Standortbestimmung hat zu lange gedauert. Versuchen Sie es erneut oder wählen Sie eine Niederlassung manuell."
+ }
+ }
+ }
+}
diff --git a/src/i18n/source/en/phrases/compat/location_permission/index.json b/src/i18n/source/en/phrases/compat/location_permission/index.json
new file mode 100644
index 00000000..2b964e1e
--- /dev/null
+++ b/src/i18n/source/en/phrases/compat/location_permission/index.json
@@ -0,0 +1,15 @@
+{
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Use your location",
+ "explanation": "Use your location to find the nearest Truck Wash department.",
+ "use_current": "Use my location",
+ "checking": "Checking location...",
+ "denied": "Location is disabled for Truck Wash. Select a department manually or allow access in iOS Settings.",
+ "unavailable": "Your location is unavailable. You can still select a department manually.",
+ "timeout": "Finding your location took too long. Try again or select a department manually."
+ }
+ }
+ }
+}
diff --git a/src/i18n/source/global/shared/location_permission/index.json b/src/i18n/source/global/shared/location_permission/index.json
new file mode 100644
index 00000000..0cf0f4bf
--- /dev/null
+++ b/src/i18n/source/global/shared/location_permission/index.json
@@ -0,0 +1,11 @@
+{
+ "location_permission": {
+ "title": "@:{'phrases.compat.location_permission.title'}",
+ "explanation": "@:{'phrases.compat.location_permission.explanation'}",
+ "use_current": "@:{'phrases.compat.location_permission.use_current'}",
+ "checking": "@:{'phrases.compat.location_permission.checking'}",
+ "denied": "@:{'phrases.compat.location_permission.denied'}",
+ "unavailable": "@:{'phrases.compat.location_permission.unavailable'}",
+ "timeout": "@:{'phrases.compat.location_permission.timeout'}"
+ }
+}
diff --git a/src/i18n/source/no/phrases/compat/location_permission/index.json b/src/i18n/source/no/phrases/compat/location_permission/index.json
new file mode 100644
index 00000000..0f0c8dc6
--- /dev/null
+++ b/src/i18n/source/no/phrases/compat/location_permission/index.json
@@ -0,0 +1,15 @@
+{
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Bruk posisjonen din",
+ "explanation": "Bruk posisjonen din til å finne nærmeste Truck Wash-avdeling.",
+ "use_current": "Bruk posisjonen min",
+ "checking": "Kontrollerer posisjon...",
+ "denied": "Posisjon er slått av for Truck Wash. Velg en avdeling manuelt, eller gi tilgang i iOS-innstillingene.",
+ "unavailable": "Posisjonen din er ikke tilgjengelig. Du kan fortsatt velge en avdeling manuelt.",
+ "timeout": "Det tok for lang tid å finne posisjonen din. Prøv igjen, eller velg en avdeling manuelt."
+ }
+ }
+ }
+}
diff --git a/src/i18n/source/sv/phrases/compat/location_permission/index.json b/src/i18n/source/sv/phrases/compat/location_permission/index.json
new file mode 100644
index 00000000..b91d3186
--- /dev/null
+++ b/src/i18n/source/sv/phrases/compat/location_permission/index.json
@@ -0,0 +1,15 @@
+{
+ "phrases": {
+ "compat": {
+ "location_permission": {
+ "title": "Använd din plats",
+ "explanation": "Använd din plats för att hitta närmaste Truck Wash-avdelning.",
+ "use_current": "Använd min plats",
+ "checking": "Kontrollerar plats...",
+ "denied": "Platsåtkomst är avstängd för Truck Wash. Välj en avdelning manuellt eller tillåt åtkomst i iOS-inställningarna.",
+ "unavailable": "Din plats är inte tillgänglig. Du kan fortfarande välja en avdelning manuellt.",
+ "timeout": "Det tog för lång tid att hitta din plats. Försök igen eller välj en avdelning manuellt."
+ }
+ }
+ }
+}
diff --git a/src/services/deviceLocation.ts b/src/services/deviceLocation.ts
new file mode 100644
index 00000000..f5c741dc
--- /dev/null
+++ b/src/services/deviceLocation.ts
@@ -0,0 +1,213 @@
+import { Capacitor } from "@capacitor/core";
+import { Geolocation, type PermissionState, type Position } from "@capacitor/geolocation";
+
+export type DeviceLocationPermission = "granted" | "prompt" | "denied" | "unavailable";
+
+export type DeviceLocationErrorCode =
+ | "permission-denied"
+ | "services-disabled"
+ | "position-unavailable"
+ | "timeout"
+ | "unknown";
+
+export type LocationRequestOptions = {
+ promptIfNeeded?: boolean;
+ enableHighAccuracy?: boolean;
+ maximumAge?: number;
+ timeout?: number;
+};
+
+export type LocationResult = {
+ permission: DeviceLocationPermission;
+ coords: {
+ latitude: number;
+ longitude: number;
+ } | null;
+ timestamp: number | null;
+ errorCode: DeviceLocationErrorCode | null;
+ errorMessage: string | null;
+};
+
+const resultWithoutPosition = (
+ permission: DeviceLocationPermission,
+ errorCode: DeviceLocationErrorCode | null = null,
+ errorMessage: string | null = null
+): LocationResult => ({
+ permission,
+ coords: null,
+ timestamp: null,
+ errorCode,
+ errorMessage,
+});
+
+const mapPermission = (permission: PermissionState | undefined): DeviceLocationPermission => {
+ if (permission === "granted") return "granted";
+ if (permission === "denied") return "denied";
+ return "prompt";
+};
+
+const mapErrorCode = (error: unknown): DeviceLocationErrorCode => {
+ const code =
+ typeof error === "object" && error !== null && "code" in error
+ ? String((error as { code?: unknown }).code)
+ : "";
+
+ if (code === "1" || code === "OS-PLUG-GLOC-0003" || code === "PERMISSION_DENIED") {
+ return "permission-denied";
+ }
+ if (code === "2" || code === "OS-PLUG-GLOC-0007" || code === "POSITION_UNAVAILABLE") {
+ return code === "OS-PLUG-GLOC-0007" ? "services-disabled" : "position-unavailable";
+ }
+ if (code === "3" || code === "OS-PLUG-GLOC-0010" || code === "TIMEOUT") {
+ return "timeout";
+ }
+ return "unknown";
+};
+
+const errorMessage = (error: unknown): string =>
+ error instanceof Error
+ ? error.message
+ : typeof error === "object" && error !== null && "message" in error
+ ? String((error as { message?: unknown }).message ?? "")
+ : String(error ?? "");
+
+const resultFromPosition = (position: Position): LocationResult => ({
+ permission: "granted",
+ coords: {
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ },
+ timestamp: Number.isFinite(position.timestamp) ? position.timestamp : Date.now(),
+ errorCode: null,
+ errorMessage: null,
+});
+
+let nativePermissionRequest: Promise
| null = null;
+let positionRequest: Promise | null = null;
+
+const requestNativePermission = async (): Promise => {
+ nativePermissionRequest ??= Geolocation.requestPermissions({ permissions: ["location"] })
+ .then((status) => mapPermission(status.location))
+ .finally(() => {
+ nativePermissionRequest = null;
+ });
+ return nativePermissionRequest;
+};
+
+const nativeLocation = async (options: Required): Promise => {
+ let permission: DeviceLocationPermission;
+ try {
+ const status = await Geolocation.checkPermissions();
+ permission = mapPermission(status.location);
+ } catch (error) {
+ return resultWithoutPosition("unavailable", mapErrorCode(error), errorMessage(error));
+ }
+
+ if (permission === "prompt" && options.promptIfNeeded) {
+ try {
+ permission = await requestNativePermission();
+ } catch (error) {
+ const code = mapErrorCode(error);
+ return resultWithoutPosition(
+ code === "permission-denied" ? "denied" : "unavailable",
+ code,
+ errorMessage(error)
+ );
+ }
+ }
+
+ if (permission !== "granted") {
+ return resultWithoutPosition(
+ permission,
+ permission === "denied" ? "permission-denied" : null
+ );
+ }
+
+ try {
+ const position = await Geolocation.getCurrentPosition({
+ enableHighAccuracy: options.enableHighAccuracy,
+ maximumAge: options.maximumAge,
+ timeout: options.timeout,
+ });
+ return resultFromPosition(position);
+ } catch (error) {
+ return resultWithoutPosition("granted", mapErrorCode(error), errorMessage(error));
+ }
+};
+
+const webPermission = async (): Promise => {
+ if (typeof navigator === "undefined" || !navigator.geolocation) {
+ return "unavailable";
+ }
+ if (!navigator.permissions?.query) {
+ return "prompt";
+ }
+ try {
+ const status = await navigator.permissions.query({ name: "geolocation" });
+ return status.state === "granted" ? "granted" : status.state === "denied" ? "denied" : "prompt";
+ } catch {
+ return "prompt";
+ }
+};
+
+const webLocation = async (options: Required): Promise => {
+ const permission = await webPermission();
+ if (permission === "unavailable") {
+ return resultWithoutPosition("unavailable", "position-unavailable");
+ }
+ if (permission === "denied") {
+ return resultWithoutPosition("denied", "permission-denied");
+ }
+ if (permission === "prompt" && !options.promptIfNeeded) {
+ return resultWithoutPosition("prompt");
+ }
+
+ return new Promise((resolve) => {
+ navigator.geolocation.getCurrentPosition(
+ (position) =>
+ resolve({
+ permission: "granted",
+ coords: {
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ },
+ timestamp: Number.isFinite(position.timestamp) ? position.timestamp : Date.now(),
+ errorCode: null,
+ errorMessage: null,
+ }),
+ (error) => {
+ const code = mapErrorCode(error);
+ resolve(
+ resultWithoutPosition(
+ code === "permission-denied" ? "denied" : permission,
+ code,
+ error.message
+ )
+ );
+ },
+ {
+ enableHighAccuracy: options.enableHighAccuracy,
+ maximumAge: options.maximumAge,
+ timeout: options.timeout,
+ }
+ );
+ });
+};
+
+export const getCurrentDeviceLocation = (
+ requestOptions: LocationRequestOptions = {}
+): Promise => {
+ const options: Required = {
+ promptIfNeeded: requestOptions.promptIfNeeded ?? false,
+ enableHighAccuracy: requestOptions.enableHighAccuracy ?? true,
+ maximumAge: requestOptions.maximumAge ?? 30_000,
+ timeout: requestOptions.timeout ?? 27_000,
+ };
+
+ positionRequest ??= (Capacitor.isNativePlatform() ? nativeLocation(options) : webLocation(options)).finally(
+ () => {
+ positionRequest = null;
+ }
+ );
+ return positionRequest;
+};
diff --git a/src/views/DefaultPage.vue b/src/views/DefaultPage.vue
index 74df61bc..da964523 100644
--- a/src/views/DefaultPage.vue
+++ b/src/views/DefaultPage.vue
@@ -252,6 +252,13 @@ const selectManualDepartment = (department) => {
redirectToDepartmentPos(department);
}
+const onLocationPermissionStateChanged = (permission) => {
+ if (permission !== "granted" && !autoSelectDepartmentResolved.value) {
+ manualDepartmentSelectionAvailable.value = true;
+ clearManualDepartmentSelectionTimeout();
+ }
+}
+
const startMobileDepartmentAutoSelect = () => {
if (autoSelectDepartmentResolved.value) {
return;
@@ -392,6 +399,7 @@ onUnmounted(() => {
{
await primeMockSession(page, { token: "user-my-wash-no-geolocation-token", bootPath: "/user/wash/start" });
+ await expect(page.getByTestId("location-permission-action")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistration(page);
await selectVehicleType(page, 2);
diff --git a/tests/unit/default-page-mobile-redirect.spec.js b/tests/unit/default-page-mobile-redirect.spec.js
index 7e43f720..5f037445 100644
--- a/tests/unit/default-page-mobile-redirect.spec.js
+++ b/tests/unit/default-page-mobile-redirect.spec.js
@@ -59,6 +59,7 @@ vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmen
return {
default: defineComponent({
name: "MockPosDepartmentStepMobile1Location",
+ emits: ["permission-state-changed"],
props: {
enableHighAccuracy: {
type: Boolean,
@@ -320,6 +321,20 @@ describe("DefaultPage mobile department redirect", () => {
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(true);
});
+ it("shows manual department selection immediately when location permission needs a user action", async () => {
+ const mounted = mountDefaultPage();
+ await nextTick();
+
+ expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(false);
+
+ mounted
+ .getComponent({ name: "MockPosDepartmentStepMobile1Location" })
+ .vm.$emit("permission-state-changed", "prompt");
+ await nextTick();
+
+ expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(true);
+ });
+
it("lets the user manually select a department after GPS wait", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [
diff --git a/tests/unit/device-location.spec.js b/tests/unit/device-location.spec.js
new file mode 100644
index 00000000..d6ae927e
--- /dev/null
+++ b/tests/unit/device-location.spec.js
@@ -0,0 +1,150 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const capacitorMocks = vi.hoisted(() => ({
+ isNativePlatform: vi.fn(),
+ checkPermissions: vi.fn(),
+ requestPermissions: vi.fn(),
+ getCurrentPosition: vi.fn(),
+}));
+
+vi.mock("@capacitor/core", () => ({
+ Capacitor: {
+ isNativePlatform: capacitorMocks.isNativePlatform,
+ },
+}));
+
+vi.mock("@capacitor/geolocation", () => ({
+ Geolocation: {
+ checkPermissions: capacitorMocks.checkPermissions,
+ requestPermissions: capacitorMocks.requestPermissions,
+ getCurrentPosition: capacitorMocks.getCurrentPosition,
+ },
+}));
+
+const position = {
+ coords: {
+ latitude: 55.6761,
+ longitude: 12.5683,
+ },
+ timestamp: 1_721_234_567_890,
+};
+
+const loadService = async () => {
+ vi.resetModules();
+ return import("@/services/deviceLocation");
+};
+
+describe("device location permission flow", () => {
+ beforeEach(() => {
+ capacitorMocks.isNativePlatform.mockReset();
+ capacitorMocks.checkPermissions.mockReset();
+ capacitorMocks.requestPermissions.mockReset();
+ capacitorMocks.getCurrentPosition.mockReset();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("does not show the native permission prompt during a silent startup check", async () => {
+ capacitorMocks.isNativePlatform.mockReturnValue(true);
+ capacitorMocks.checkPermissions.mockResolvedValue({ location: "prompt" });
+
+ const { getCurrentDeviceLocation } = await loadService();
+ const result = await getCurrentDeviceLocation({ promptIfNeeded: false });
+
+ expect(result.permission).toBe("prompt");
+ expect(result.coords).toBeNull();
+ expect(capacitorMocks.requestPermissions).not.toHaveBeenCalled();
+ expect(capacitorMocks.getCurrentPosition).not.toHaveBeenCalled();
+ });
+
+ it("requests native permission only after the explicit location action", async () => {
+ capacitorMocks.isNativePlatform.mockReturnValue(true);
+ capacitorMocks.checkPermissions.mockResolvedValue({ location: "prompt" });
+ capacitorMocks.requestPermissions.mockResolvedValue({ location: "granted" });
+ capacitorMocks.getCurrentPosition.mockResolvedValue(position);
+
+ const { getCurrentDeviceLocation } = await loadService();
+ const result = await getCurrentDeviceLocation({ promptIfNeeded: true });
+
+ expect(capacitorMocks.requestPermissions).toHaveBeenCalledTimes(1);
+ expect(capacitorMocks.getCurrentPosition).toHaveBeenCalledTimes(1);
+ expect(result).toMatchObject({
+ permission: "granted",
+ coords: position.coords,
+ timestamp: position.timestamp,
+ errorCode: null,
+ });
+ });
+
+ it("silently reuses a persistent native grant on later app starts", async () => {
+ capacitorMocks.isNativePlatform.mockReturnValue(true);
+ capacitorMocks.checkPermissions.mockResolvedValue({ location: "granted" });
+ capacitorMocks.getCurrentPosition.mockResolvedValue(position);
+
+ const { getCurrentDeviceLocation } = await loadService();
+ const result = await getCurrentDeviceLocation();
+
+ expect(capacitorMocks.requestPermissions).not.toHaveBeenCalled();
+ expect(capacitorMocks.getCurrentPosition).toHaveBeenCalledTimes(1);
+ expect(result.coords).toEqual(position.coords);
+ });
+
+ it("does not reprompt after native permission has been denied", async () => {
+ capacitorMocks.isNativePlatform.mockReturnValue(true);
+ capacitorMocks.checkPermissions.mockResolvedValue({ location: "denied" });
+
+ const { getCurrentDeviceLocation } = await loadService();
+ const result = await getCurrentDeviceLocation({ promptIfNeeded: true });
+
+ expect(result).toMatchObject({
+ permission: "denied",
+ errorCode: "permission-denied",
+ });
+ expect(capacitorMocks.requestPermissions).not.toHaveBeenCalled();
+ expect(capacitorMocks.getCurrentPosition).not.toHaveBeenCalled();
+ });
+
+ it("does not call browser geolocation while web permission is still prompt", async () => {
+ capacitorMocks.isNativePlatform.mockReturnValue(false);
+ const getCurrentPosition = vi.fn();
+ vi.stubGlobal("navigator", {
+ permissions: {
+ query: vi.fn().mockResolvedValue({ state: "prompt" }),
+ },
+ geolocation: {
+ getCurrentPosition,
+ },
+ });
+
+ const { getCurrentDeviceLocation } = await loadService();
+ const result = await getCurrentDeviceLocation();
+
+ expect(result.permission).toBe("prompt");
+ expect(getCurrentPosition).not.toHaveBeenCalled();
+ });
+
+ it("uses one browser position request when permission is already granted", async () => {
+ capacitorMocks.isNativePlatform.mockReturnValue(false);
+ const watchPosition = vi.fn();
+ const getCurrentPosition = vi.fn((success) => success(position));
+ vi.stubGlobal("navigator", {
+ permissions: {
+ query: vi.fn().mockResolvedValue({ state: "granted" }),
+ },
+ geolocation: {
+ getCurrentPosition,
+ watchPosition,
+ },
+ });
+
+ const { getCurrentDeviceLocation } = await loadService();
+ const result = await getCurrentDeviceLocation();
+
+ expect(result.coords).toEqual(position.coords);
+ expect(getCurrentPosition).toHaveBeenCalledTimes(1);
+ expect(watchPosition).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/mobile-header-safe-area.spec.js b/tests/unit/mobile-header-safe-area.spec.js
index 9c5a0e8f..901774c5 100644
--- a/tests/unit/mobile-header-safe-area.spec.js
+++ b/tests/unit/mobile-header-safe-area.spec.js
@@ -8,6 +8,9 @@ const mainStylesSource = readFileSync(join(root, "src/assets/main.css"), "utf8")
const mobileHeaderSource = readFileSync(join(root, "src/components/viewport/page/headers/MobileHeader.vue"), "utf8");
const desktopHeaderSource = readFileSync(join(root, "src/components/viewport/page/headers/DesktopHeader.vue"), "utf8");
const tabletHeaderSource = readFileSync(join(root, "src/components/viewport/page/headers/TabletHeader.vue"), "utf8");
+const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
+const capacitorConfigSource = readFileSync(join(root, "capacitor.config.ts"), "utf8");
+const iosInfoPlistSource = readFileSync(join(root, "ios/App/App/Info.plist"), "utf8");
describe("mobile header iOS safe-area contract", () => {
it("opts into safe-area viewport geometry", () => {
@@ -25,4 +28,12 @@ describe("mobile header iOS safe-area contract", () => {
expect(desktopHeaderSource).not.toContain("safe-area-inset-top");
expect(tabletHeaderSource).not.toContain("safe-area-inset-top");
});
+
+ it("keeps the native iOS status bar outside the Capacitor web view", () => {
+ expect(packageJson.dependencies["@capacitor/status-bar"]).toBe("^8.0.3");
+ expect(capacitorConfigSource).toMatch(
+ /StatusBar:\s*\{[\s\S]*overlaysWebView:\s*false,[\s\S]*style:\s*"LIGHT",[\s\S]*backgroundColor:\s*"#FFFFFFFF"/
+ );
+ expect(iosInfoPlistSource).toMatch(/UIViewControllerBasedStatusBarAppearance<\/key>\s*/);
+ });
});
diff --git a/tests/unit/pos-mobile-location-permission.spec.js b/tests/unit/pos-mobile-location-permission.spec.js
new file mode 100644
index 00000000..5a62fb5d
--- /dev/null
+++ b/tests/unit/pos-mobile-location-permission.spec.js
@@ -0,0 +1,90 @@
+// @vitest-environment jsdom
+import { defineComponent, h, nextTick } from "vue";
+import { mount } from "@vue/test-utils";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const locationMocks = vi.hoisted(() => ({
+ getCurrentDeviceLocation: vi.fn(),
+ set: vi.fn(),
+}));
+
+vi.mock("@/services/deviceLocation", () => ({
+ getCurrentDeviceLocation: locationMocks.getCurrentDeviceLocation,
+}));
+
+vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
+ locations: {
+ normalizeCoordinatePair: (coords) => coords,
+ set: locationMocks.set,
+ },
+ default: defineComponent({
+ render: () => h("div"),
+ }),
+}));
+
+import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
+
+const mountLocation = () =>
+ mount(PosDepartmentStepMobile1Location, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ },
+ },
+ });
+
+describe("mobile location permission control", () => {
+ beforeEach(() => {
+ locationMocks.getCurrentDeviceLocation.mockReset();
+ locationMocks.set.mockReset();
+ });
+
+ it("checks silently on mount and prompts only from the location button", async () => {
+ locationMocks.getCurrentDeviceLocation
+ .mockResolvedValueOnce({
+ permission: "prompt",
+ coords: null,
+ timestamp: null,
+ errorCode: null,
+ errorMessage: null,
+ })
+ .mockResolvedValueOnce({
+ permission: "granted",
+ coords: {
+ latitude: 55.6761,
+ longitude: 12.5683,
+ },
+ timestamp: 1_721_234_567_890,
+ errorCode: null,
+ errorMessage: null,
+ });
+
+ const wrapper = mountLocation();
+ await nextTick();
+ await nextTick();
+
+ expect(locationMocks.getCurrentDeviceLocation).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ promptIfNeeded: false })
+ );
+ expect(wrapper.get('[data-testid="location-permission-control"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="location-permission-action"]').trigger("click");
+ await nextTick();
+ await nextTick();
+
+ expect(locationMocks.getCurrentDeviceLocation).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ promptIfNeeded: true })
+ );
+ expect(locationMocks.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ coords: {
+ latitude: 55.6761,
+ longitude: 12.5683,
+ },
+ })
+ );
+ expect(wrapper.emitted("location-updated")).toEqual([[{ latitude: 55.6761, longitude: 12.5683 }]]);
+ });
+});