Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 7c82d9f523 fix(i18n): compile location permission compatibility paths 2026-07-23 20:01:25 +02:00
Jeppe Bundgaard e1092b80af test(ios): cover visible location fallback action 2026-07-23 19:06:54 +02:00
Jeppe Bundgaard ab75f134fd style(tests): apply repository formatting 2026-07-23 19:05:32 +02:00
Jeppe Bundgaard f2b209f078 fix(ios): respect status area and persist location consent
Keep the native status bar outside the web view and replace the startup geolocation watcher with a silent permission check plus an explicit location action.
2026-07-23 19:01:49 +02:00
25 changed files with 743 additions and 38 deletions
+7
View File
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
server: { server: {
androidScheme: "https", androidScheme: "https",
}, },
plugins: {
StatusBar: {
overlaysWebView: false,
style: "LIGHT",
backgroundColor: "#FFFFFFFF",
},
},
}; };
export default config; export default config;
+4 -2
View File
@@ -12,7 +12,8 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"), .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: [ targets: [
.target( .target(
@@ -20,7 +21,8 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", 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")
] ]
) )
] ]
+9
View File
@@ -12,6 +12,7 @@
"@bubblewrap/cli": "^1.24.1", "@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1", "@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0", "@capacitor/geolocation": "^8.2.0",
"@capacitor/status-bar": "^8.0.3",
"@creativebulma/bulma-badge": "^1.0.1", "@creativebulma/bulma-badge": "^1.0.1",
"@fullcalendar/core": "^6.1.17", "@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17",
@@ -2132,6 +2133,14 @@
"@capacitor/core": "^8.4.0" "@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": { "node_modules/@capacitor/synapse": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
+1
View File
@@ -84,6 +84,7 @@
"@bubblewrap/cli": "^1.24.1", "@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1", "@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0", "@capacitor/geolocation": "^8.2.0",
"@capacitor/status-bar": "^8.0.3",
"@creativebulma/bulma-badge": "^1.0.1", "@creativebulma/bulma-badge": "^1.0.1",
"@fullcalendar/core": "^6.1.17", "@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17",
@@ -25,7 +25,6 @@ import {
import { PosSearchResult } from "./objects/PosSearchResult.vue"; import { PosSearchResult } from "./objects/PosSearchResult.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue"; import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.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 PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.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"; import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
@@ -778,8 +777,6 @@ watch(
</div> </div>
<!-- Debug --> <!-- Debug -->
<PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results" /> <PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results" />
<!-- Location -->
<PosDepartmentStepMobile1Location />
<!-- Buttons --> <!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :use-backdrop-blur="views.attachmentView.value"> <PosDepartmentStepMobileFixedBottomControl variant="pos-step" :use-backdrop-blur="views.attachmentView.value">
<!-- Attachments --> <!-- Attachments -->
@@ -1,68 +1,139 @@
<script setup lang="ts"> <script setup lang="ts">
import { useGeolocation } from "@vueuse/core"; import { computed, onMounted, ref } from "vue";
import { watch } from "vue"; import {
getCurrentDeviceLocation,
type DeviceLocationErrorCode,
type DeviceLocationPermission,
} from "@/services/deviceLocation";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue"; import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
enableHighAccuracy?: boolean; enableHighAccuracy?: boolean;
maximumAge?: number; maximumAge?: number;
timeout?: number; timeout?: number;
autoLocateIfGranted?: boolean;
showPermissionControl?: boolean;
}>(), { }>(), {
enableHighAccuracy: true, enableHighAccuracy: true,
maximumAge: 30000, maximumAge: 30000,
timeout: 27000, timeout: 27000,
autoLocateIfGranted: true,
showPermissionControl: true,
}); });
const emits = defineEmits<{ const emits = defineEmits<{
"location-updated": [coords: { latitude: number | null; longitude: number | null }]; "location-updated": [coords: { latitude: number | null; longitude: number | null }];
"permission-state-changed": [permission: DeviceLocationPermission];
"location-error": [error: { code: DeviceLocationErrorCode; message: string | null }];
}>(); }>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({ const permission = ref<DeviceLocationPermission | null>(null);
enableHighAccuracy: props.enableHighAccuracy, const errorCode = ref<DeviceLocationErrorCode | null>(null);
maximumAge: props.maximumAge, const errorMessage = ref<string | null>(null);
timeout: props.timeout, const isChecking = ref(false);
}); const isRequesting = ref(false);
const getLocationTimestamp = () => { const shouldShowControl = computed(
const timestamp = Number(locatedAt.value); () => props.showPermissionControl && (permission.value !== "granted" || errorCode.value !== null)
return Number.isFinite(timestamp) ? timestamp : Date.now(); );
}; const canRequestPermission = computed(() => permission.value === "prompt");
const isBusy = computed(() => isChecking.value || isRequesting.value);
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => { const updateLocation = (coords: { latitude: number; longitude: number }, timestamp: number) => {
const normalizedCoords = locations.normalizeCoordinatePair(newCoords); const normalizedCoords = locations.normalizeCoordinatePair(coords);
if (normalizedCoords) { if (normalizedCoords) {
const timestamp = getLocationTimestamp();
locations.set({ locations.set({
coords: normalizedCoords, coords: normalizedCoords,
timestamp: new Date(timestamp), timestamp: new Date(timestamp),
locatedAt: timestamp, locatedAt: timestamp,
errorMessage: error.value ? error.value.message : null, errorMessage: null,
}) });
emits('location-updated', normalizedCoords); emits("location-updated", normalizedCoords);
} }
}; };
watch(coords, (newCoords) => {
onUpdate(newCoords); const locate = async (promptIfNeeded: boolean) => {
if (isBusy.value) return;
errorCode.value = null;
errorMessage.value = null;
isChecking.value = !promptIfNeeded;
isRequesting.value = promptIfNeeded;
try {
const result = await getCurrentDeviceLocation({
promptIfNeeded,
enableHighAccuracy: props.enableHighAccuracy,
maximumAge: props.maximumAge,
timeout: props.timeout,
});
permission.value = result.permission;
emits("permission-state-changed", result.permission);
if (result.coords && result.timestamp !== null) {
updateLocation(result.coords, result.timestamp);
}
if (result.errorCode) {
errorCode.value = result.errorCode;
errorMessage.value = result.errorMessage;
emits("location-error", {
code: result.errorCode,
message: result.errorMessage,
});
}
} finally {
isChecking.value = false;
isRequesting.value = false;
}
};
const onLocationAction = () => locate(canRequestPermission.value);
onMounted(() => {
if (props.autoLocateIfGranted) {
locate(false);
}
}); });
</script> </script>
<template> <template>
<div v-show="false"> <div
<p>Latitude: {{ coords.latitude }}</p> v-if="shouldShowControl"
<p>Longitude: {{ coords.longitude }}</p> class="location-permission notification is-light"
<p>Located At: {{ new Date(locatedAt).toLocaleString() }}</p> data-testid="location-permission-control"
<p v-if="error">Error: {{ error.message }}</p> >
<template v-if="false"> <p class="has-text-weight-semibold">{{ $t("location_permission.title") }}</p>
<button @click="resume">Resume</button> <p v-if="permission === 'denied'" class="is-size-7" data-testid="location-permission-denied">
<button @click="pause">Pause</button> {{ $t("location_permission.denied") }}
<button @click="onUpdate({ </p>
latitude: 55.613448, <p v-else-if="permission === 'unavailable'" class="is-size-7" data-testid="location-permission-unavailable">
longitude: 12.495521 {{ $t("location_permission.unavailable") }}
})">Update Now</button> </p>
</template> <p v-else-if="errorCode === 'timeout'" class="is-size-7" data-testid="location-permission-timeout">
{{ $t("location_permission.timeout") }}
</p>
<p v-else class="is-size-7">{{ $t("location_permission.explanation") }}</p>
<button
type="button"
class="button is-link is-fullwidth mt-3"
:class="{ 'is-loading': isBusy }"
:disabled="isBusy"
data-testid="location-permission-action"
@click="onLocationAction"
>
{{
isBusy
? $t("location_permission.checking")
: canRequestPermission
? $t("location_permission.use_current")
: $t("common.retry")
}}
</button>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.location-permission {
margin: 1rem;
text-align: center;
}
</style> </style>
+9
View File
@@ -4527,6 +4527,15 @@
"succeeded": "@.capitalize:{'words.generated.succesfuld'}" "succeeded": "@.capitalize:{'words.generated.succesfuld'}"
} }
}, },
"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."
},
"login_qr": { "login_qr": {
"camera_error": "@.capitalize:{'words.generated.kamera'} @:{'words.generated.fejl'}", "camera_error": "@.capitalize:{'words.generated.kamera'} @:{'words.generated.fejl'}",
"login_with_password_instead": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.med'} @:{'words.generated.adgangskode'} @:{'words.generated.i'} @:{'words.generated.stedet'}", "login_with_password_instead": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.med'} @:{'words.generated.adgangskode'} @:{'words.generated.i'} @:{'words.generated.stedet'}",
+9
View File
@@ -4637,6 +4637,15 @@
"succeeded": "@:{'templates.generated.compat.global.succeeded'}" "succeeded": "@:{'templates.generated.compat.global.succeeded'}"
} }
}, },
"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."
},
"login_qr": { "login_qr": {
"camera_error": "Kamerafehler", "camera_error": "Kamerafehler",
"login_with_password_instead": "Stattdessen @:{'words.generated.mit'} @:{'words.generated.passwort'} @:{'words.generated.anmelden'}", "login_with_password_instead": "Stattdessen @:{'words.generated.mit'} @:{'words.generated.passwort'} @:{'words.generated.anmelden'}",
+9
View File
@@ -4358,6 +4358,15 @@
"succeeded": "@.capitalize:{'words.generated.successful'}" "succeeded": "@.capitalize:{'words.generated.successful'}"
} }
}, },
"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."
},
"login_qr": { "login_qr": {
"camera_error": "@.capitalize:{'words.generated.camera'} @.capitalize:{'words.generated.error'}", "camera_error": "@.capitalize:{'words.generated.camera'} @.capitalize:{'words.generated.error'}",
"login_with_password_instead": "@.capitalize:{'words.generated.login'} @:{'words.generated.with'} @:{'words.generated.password'} @:{'words.generated.instead'}", "login_with_password_instead": "@.capitalize:{'words.generated.login'} @:{'words.generated.with'} @:{'words.generated.password'} @:{'words.generated.instead'}",
+9
View File
@@ -3693,6 +3693,15 @@
"succeeded": "@:{'templates.generated.compat.invoicing.progress.succeeded'}" "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": { "login_qr": {
"camera_error": "@:{'templates.generated.compat.login_qr.camera_error'}", "camera_error": "@:{'templates.generated.compat.login_qr.camera_error'}",
"login_with_password_instead": "@:{'templates.generated.compat.login_qr.login_with_password_instead'}", "login_with_password_instead": "@:{'templates.generated.compat.login_qr.login_with_password_instead'}",
+9
View File
@@ -4640,6 +4640,15 @@
"succeeded": "@.capitalize:{'words.generated.vellykket'}" "succeeded": "@.capitalize:{'words.generated.vellykket'}"
} }
}, },
"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."
},
"login_qr": { "login_qr": {
"camera_error": "Kamerafeil", "camera_error": "Kamerafeil",
"login_with_password_instead": "@.capitalize:{'words.generated.logg'} @:{'words.generated.pa'} @:{'words.generated.med'} @:{'words.generated.passord'} @:{'words.generated.i'} @:{'words.generated.stedet'}", "login_with_password_instead": "@.capitalize:{'words.generated.logg'} @:{'words.generated.pa'} @:{'words.generated.med'} @:{'words.generated.passord'} @:{'words.generated.i'} @:{'words.generated.stedet'}",
+9
View File
@@ -4690,6 +4690,15 @@
"succeeded": "Lyckad" "succeeded": "Lyckad"
} }
}, },
"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."
},
"login_qr": { "login_qr": {
"camera_error": "Kamerafel", "camera_error": "Kamerafel",
"login_with_password_instead": "@.capitalize:{'words.generated.logga'} @:{'words.generated.in'} @:{'words.generated.med'} @:{'words.generated.losenord'} @:{'words.generated.i'} stället", "login_with_password_instead": "@.capitalize:{'words.generated.logga'} @:{'words.generated.in'} @:{'words.generated.med'} @:{'words.generated.losenord'} @:{'words.generated.i'} stället",
@@ -0,0 +1,13 @@
{
"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."
}
}
}
@@ -0,0 +1,13 @@
{
"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."
}
}
}
@@ -0,0 +1,13 @@
{
"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."
}
}
}
@@ -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'}"
}
}
@@ -0,0 +1,13 @@
{
"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."
}
}
}
@@ -0,0 +1,13 @@
{
"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."
}
}
}
+213
View File
@@ -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<DeviceLocationPermission> | null = null;
let positionRequest: Promise<LocationResult> | null = null;
const requestNativePermission = async (): Promise<DeviceLocationPermission> => {
nativePermissionRequest ??= Geolocation.requestPermissions({ permissions: ["location"] })
.then((status) => mapPermission(status.location))
.finally(() => {
nativePermissionRequest = null;
});
return nativePermissionRequest;
};
const nativeLocation = async (options: Required<LocationRequestOptions>): Promise<LocationResult> => {
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<DeviceLocationPermission> => {
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<LocationRequestOptions>): Promise<LocationResult> => {
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<LocationResult> => {
const options: Required<LocationRequestOptions> = {
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;
};
+8
View File
@@ -252,6 +252,13 @@ const selectManualDepartment = (department) => {
redirectToDepartmentPos(department); redirectToDepartmentPos(department);
} }
const onLocationPermissionStateChanged = (permission) => {
if (permission !== "granted" && !autoSelectDepartmentResolved.value) {
manualDepartmentSelectionAvailable.value = true;
clearManualDepartmentSelectionTimeout();
}
}
const startMobileDepartmentAutoSelect = () => { const startMobileDepartmentAutoSelect = () => {
if (autoSelectDepartmentResolved.value) { if (autoSelectDepartmentResolved.value) {
return; return;
@@ -392,6 +399,7 @@ onUnmounted(() => {
<PosDepartmentStepMobile1Location <PosDepartmentStepMobile1Location
:maximum-age="autoSelectGeolocationMaximumAge" :maximum-age="autoSelectGeolocationMaximumAge"
:timeout="autoSelectGeolocationTimeout" :timeout="autoSelectGeolocationTimeout"
@permission-state-changed="onLocationPermissionStateChanged"
/> />
<div <div
v-if="manualDepartmentSelectionAvailable && !autoSelectDepartmentResolved" v-if="manualDepartmentSelectionAvailable && !autoSelectDepartmentResolved"
+1
View File
@@ -373,6 +373,7 @@ test.describe("User MyWashStart mocked flow", () => {
await primeMockSession(page, { token: "user-my-wash-no-geolocation-token", bootPath: "/user/wash/start" }); 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 expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistration(page); await fillRegistration(page);
await selectVehicleType(page, 2); await selectVehicleType(page, 2);
@@ -59,6 +59,7 @@ vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmen
return { return {
default: defineComponent({ default: defineComponent({
name: "MockPosDepartmentStepMobile1Location", name: "MockPosDepartmentStepMobile1Location",
emits: ["permission-state-changed"],
props: { props: {
enableHighAccuracy: { enableHighAccuracy: {
type: Boolean, type: Boolean,
@@ -320,6 +321,20 @@ describe("DefaultPage mobile department redirect", () => {
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(true); 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 () => { it("lets the user manually select a department after GPS wait", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]); setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [ __departmentsRef.value = [
+150
View File
@@ -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();
});
});
@@ -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 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 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 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", () => { describe("mobile header iOS safe-area contract", () => {
it("opts into safe-area viewport geometry", () => { 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(desktopHeaderSource).not.toContain("safe-area-inset-top");
expect(tabletHeaderSource).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(/<key>UIViewControllerBasedStatusBarAppearance<\/key>\s*<true\/>/);
});
}); });
@@ -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 }]]);
});
});