diff --git a/capacitor.config.ts b/capacitor.config.ts index 46e574a9..3b2da61b 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -7,6 +7,13 @@ const config: CapacitorConfig = { server: { androidScheme: "https", }, + plugins: { + StatusBar: { + overlaysWebView: false, + style: "LIGHT", + backgroundColor: "#FFFFFFFF", + }, + }, }; export default config; diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 6c94d772..46f14636 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -12,7 +12,8 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"), - .package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation") + .package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") ], targets: [ .target( @@ -20,7 +21,8 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), - .product(name: "CapacitorGeolocation", package: "CapacitorGeolocation") + .product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") ] ) ] diff --git a/package-lock.json b/package-lock.json index f1e1349d..df7ce32a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@bubblewrap/cli": "^1.24.1", "@capacitor/core": "^8.4.1", "@capacitor/geolocation": "^8.2.0", + "@capacitor/status-bar": "^8.0.3", "@creativebulma/bulma-badge": "^1.0.1", "@fullcalendar/core": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17", @@ -2132,6 +2133,14 @@ "@capacitor/core": "^8.4.0" } }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz", + "integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/synapse": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", diff --git a/package.json b/package.json index d871e997..9d413a92 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@bubblewrap/cli": "^1.24.1", "@capacitor/core": "^8.4.1", "@capacitor/geolocation": "^8.2.0", + "@capacitor/status-bar": "^8.0.3", "@creativebulma/bulma-badge": "^1.0.1", "@fullcalendar/core": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17", diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue index 3811da4f..7f1c9489 100644 --- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue +++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue @@ -25,7 +25,6 @@ import { import { PosSearchResult } from "./objects/PosSearchResult.vue"; import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue"; import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue"; -import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue"; import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue"; import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue"; import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue"; @@ -778,8 +777,6 @@ watch( - - diff --git a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue index 3a2b9f5a..c11f6b5a 100644 --- a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue +++ b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue @@ -1,68 +1,139 @@ 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 }]]); + }); +});