Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c82d9f523 | ||
|
|
e1092b80af | ||
|
|
ab75f134fd | ||
|
|
f2b209f078 |
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
|
||||
server: {
|
||||
androidScheme: "https",
|
||||
},
|
||||
plugins: {
|
||||
StatusBar: {
|
||||
overlaysWebView: false,
|
||||
style: "LIGHT",
|
||||
backgroundColor: "#FFFFFFFF",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+9
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
</div>
|
||||
<!-- Debug -->
|
||||
<PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results" />
|
||||
<!-- Location -->
|
||||
<PosDepartmentStepMobile1Location />
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :use-backdrop-blur="views.attachmentView.value">
|
||||
<!-- Attachments -->
|
||||
|
||||
+104
-33
@@ -1,68 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { useGeolocation } from "@vueuse/core";
|
||||
import { watch } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import {
|
||||
getCurrentDeviceLocation,
|
||||
type DeviceLocationErrorCode,
|
||||
type DeviceLocationPermission,
|
||||
} from "@/services/deviceLocation";
|
||||
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
enableHighAccuracy?: boolean;
|
||||
maximumAge?: number;
|
||||
timeout?: number;
|
||||
autoLocateIfGranted?: boolean;
|
||||
showPermissionControl?: boolean;
|
||||
}>(), {
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 30000,
|
||||
timeout: 27000,
|
||||
autoLocateIfGranted: true,
|
||||
showPermissionControl: true,
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
"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({
|
||||
enableHighAccuracy: props.enableHighAccuracy,
|
||||
maximumAge: props.maximumAge,
|
||||
timeout: props.timeout,
|
||||
});
|
||||
const permission = ref<DeviceLocationPermission | null>(null);
|
||||
const errorCode = ref<DeviceLocationErrorCode | null>(null);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const isChecking = ref(false);
|
||||
const isRequesting = ref(false);
|
||||
|
||||
const getLocationTimestamp = () => {
|
||||
const timestamp = Number(locatedAt.value);
|
||||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||
};
|
||||
const shouldShowControl = computed(
|
||||
() => props.showPermissionControl && (permission.value !== "granted" || errorCode.value !== null)
|
||||
);
|
||||
const canRequestPermission = computed(() => permission.value === "prompt");
|
||||
const isBusy = computed(() => isChecking.value || isRequesting.value);
|
||||
|
||||
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
|
||||
const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
|
||||
const updateLocation = (coords: { latitude: number; longitude: number }, timestamp: number) => {
|
||||
const normalizedCoords = locations.normalizeCoordinatePair(coords);
|
||||
if (normalizedCoords) {
|
||||
const timestamp = getLocationTimestamp();
|
||||
locations.set({
|
||||
coords: normalizedCoords,
|
||||
timestamp: new Date(timestamp),
|
||||
locatedAt: timestamp,
|
||||
errorMessage: error.value ? error.value.message : null,
|
||||
})
|
||||
emits('location-updated', normalizedCoords);
|
||||
errorMessage: null,
|
||||
});
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div v-show="false">
|
||||
<p>Latitude: {{ coords.latitude }}</p>
|
||||
<p>Longitude: {{ coords.longitude }}</p>
|
||||
<p>Located At: {{ new Date(locatedAt).toLocaleString() }}</p>
|
||||
<p v-if="error">Error: {{ error.message }}</p>
|
||||
<template v-if="false">
|
||||
<button @click="resume">Resume</button>
|
||||
<button @click="pause">Pause</button>
|
||||
<button @click="onUpdate({
|
||||
latitude: 55.613448,
|
||||
longitude: 12.495521
|
||||
})">Update Now</button>
|
||||
</template>
|
||||
<div
|
||||
v-if="shouldShowControl"
|
||||
class="location-permission notification is-light"
|
||||
data-testid="location-permission-control"
|
||||
>
|
||||
<p class="has-text-weight-semibold">{{ $t("location_permission.title") }}</p>
|
||||
<p v-if="permission === 'denied'" class="is-size-7" data-testid="location-permission-denied">
|
||||
{{ $t("location_permission.denied") }}
|
||||
</p>
|
||||
<p v-else-if="permission === 'unavailable'" class="is-size-7" data-testid="location-permission-unavailable">
|
||||
{{ $t("location_permission.unavailable") }}
|
||||
</p>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.location-permission {
|
||||
margin: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4527,6 +4527,15 @@
|
||||
"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": {
|
||||
"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'}",
|
||||
|
||||
@@ -4637,6 +4637,15 @@
|
||||
"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": {
|
||||
"camera_error": "Kamerafehler",
|
||||
"login_with_password_instead": "Stattdessen @:{'words.generated.mit'} @:{'words.generated.passwort'} @:{'words.generated.anmelden'}",
|
||||
|
||||
@@ -4358,6 +4358,15 @@
|
||||
"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": {
|
||||
"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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -4640,6 +4640,15 @@
|
||||
"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": {
|
||||
"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'}",
|
||||
|
||||
@@ -4690,6 +4690,15 @@
|
||||
"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": {
|
||||
"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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(() => {
|
||||
<PosDepartmentStepMobile1Location
|
||||
:maximum-age="autoSelectGeolocationMaximumAge"
|
||||
:timeout="autoSelectGeolocationTimeout"
|
||||
@permission-state-changed="onLocationPermissionStateChanged"
|
||||
/>
|
||||
<div
|
||||
v-if="manualDepartmentSelectionAvailable && !autoSelectDepartmentResolved"
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 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(/<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 }]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user