Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c82d9f523 | ||
|
|
e1092b80af | ||
|
|
ab75f134fd | ||
|
|
f2b209f078 |
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+9
@@ -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",
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
+104
-33
@@ -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>
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
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"
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 }]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user