fix(ios): respect status area and persist location consent (#224)
Keep the iOS status bar outside the Capacitor web view and replace startup geolocation watching with silent permission checks plus an explicit location action. Verified by full unit, App Store readiness, Qodana, production build, Capacitor sync, and Playwright mobile suites.
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -1145,6 +1145,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Brug din placering",
|
||||||
|
"explanation": "Brug din placering til at finde den nærmeste Truck Wash-afdeling.",
|
||||||
|
"use_current": "Brug min placering",
|
||||||
|
"checking": "Kontrollerer placering...",
|
||||||
|
"denied": "Placering er slået fra for Truck Wash. Vælg en afdeling manuelt, eller giv adgang i iOS-indstillingerne.",
|
||||||
|
"unavailable": "Din placering er ikke tilgængelig. Du kan stadig vælge en afdeling manuelt.",
|
||||||
|
"timeout": "Det tog for lang tid at finde din placering. Prøv igen, eller vælg en afdeling manuelt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "@.capitalize:{'words.generated.support'}",
|
"title": "@.capitalize:{'words.generated.support'}",
|
||||||
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktoplysninger @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
|
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktoplysninger @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
|
||||||
|
|||||||
@@ -1255,6 +1255,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Standort verwenden",
|
||||||
|
"explanation": "Verwenden Sie Ihren Standort, um die nächste Truck Wash-Niederlassung zu finden.",
|
||||||
|
"use_current": "Meinen Standort verwenden",
|
||||||
|
"checking": "Standort wird geprüft...",
|
||||||
|
"denied": "Der Standortzugriff ist für Truck Wash deaktiviert. Wählen Sie eine Niederlassung manuell oder erlauben Sie den Zugriff in den iOS-Einstellungen.",
|
||||||
|
"unavailable": "Ihr Standort ist nicht verfügbar. Sie können weiterhin eine Niederlassung manuell auswählen.",
|
||||||
|
"timeout": "Die Standortbestimmung hat zu lange gedauert. Versuchen Sie es erneut oder wählen Sie eine Niederlassung manuell."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "@:{'words.generated.support'}",
|
"title": "@:{'words.generated.support'}",
|
||||||
"meta_description": "@:{'words.generated.support'}- @:{'words.generated.und'} Kontaktinformationen @:{'words.generated.fuer'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
|
"meta_description": "@:{'words.generated.support'}- @:{'words.generated.und'} Kontaktinformationen @:{'words.generated.fuer'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
|
||||||
|
|||||||
@@ -976,6 +976,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Use your location",
|
||||||
|
"explanation": "Use your location to find the nearest Truck Wash department.",
|
||||||
|
"use_current": "Use my location",
|
||||||
|
"checking": "Checking location...",
|
||||||
|
"denied": "Location is disabled for Truck Wash. Select a department manually or allow access in iOS Settings.",
|
||||||
|
"unavailable": "Your location is unavailable. You can still select a department manually.",
|
||||||
|
"timeout": "Finding your location took too long. Try again or select a department manually."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "@.capitalize:{'words.generated.support'}",
|
"title": "@.capitalize:{'words.generated.support'}",
|
||||||
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.and'} @:{'words.generated.contact'} @:{'words.generated.information'} @:{'words.generated.for'} @.capitalize:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
|
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.and'} @:{'words.generated.contact'} @:{'words.generated.information'} @:{'words.generated.for'} @.capitalize:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -1258,6 +1258,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Bruk posisjonen din",
|
||||||
|
"explanation": "Bruk posisjonen din til å finne nærmeste Truck Wash-avdeling.",
|
||||||
|
"use_current": "Bruk posisjonen min",
|
||||||
|
"checking": "Kontrollerer posisjon...",
|
||||||
|
"denied": "Posisjon er slått av for Truck Wash. Velg en avdeling manuelt, eller gi tilgang i iOS-innstillingene.",
|
||||||
|
"unavailable": "Posisjonen din er ikke tilgjengelig. Du kan fortsatt velge en avdeling manuelt.",
|
||||||
|
"timeout": "Det tok for lang tid å finne posisjonen din. Prøv igjen, eller velg en avdeling manuelt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "@.capitalize:{'words.generated.support'}",
|
"title": "@.capitalize:{'words.generated.support'}",
|
||||||
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktinformasjon @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
|
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktinformasjon @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
|
||||||
|
|||||||
@@ -1308,6 +1308,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Använd din plats",
|
||||||
|
"explanation": "Använd din plats för att hitta närmaste Truck Wash-avdelning.",
|
||||||
|
"use_current": "Använd min plats",
|
||||||
|
"checking": "Kontrollerar plats...",
|
||||||
|
"denied": "Platsåtkomst är avstängd för Truck Wash. Välj en avdelning manuellt eller tillåt åtkomst i iOS-inställningarna.",
|
||||||
|
"unavailable": "Din plats är inte tillgänglig. Du kan fortfarande välja en avdelning manuellt.",
|
||||||
|
"timeout": "Det tog för lång tid att hitta din plats. Försök igen eller välj en avdelning manuellt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "@.capitalize:{'words.generated.support'}",
|
"title": "@.capitalize:{'words.generated.support'}",
|
||||||
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.och'} kontaktinformation @:{'words.generated.for_2'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
|
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.och'} kontaktinformation @:{'words.generated.for_2'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Brug din placering",
|
||||||
|
"explanation": "Brug din placering til at finde den nærmeste Truck Wash-afdeling.",
|
||||||
|
"use_current": "Brug min placering",
|
||||||
|
"checking": "Kontrollerer placering...",
|
||||||
|
"denied": "Placering er slået fra for Truck Wash. Vælg en afdeling manuelt, eller giv adgang i iOS-indstillingerne.",
|
||||||
|
"unavailable": "Din placering er ikke tilgængelig. Du kan stadig vælge en afdeling manuelt.",
|
||||||
|
"timeout": "Det tog for lang tid at finde din placering. Prøv igen, eller vælg en afdeling manuelt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Standort verwenden",
|
||||||
|
"explanation": "Verwenden Sie Ihren Standort, um die nächste Truck Wash-Niederlassung zu finden.",
|
||||||
|
"use_current": "Meinen Standort verwenden",
|
||||||
|
"checking": "Standort wird geprüft...",
|
||||||
|
"denied": "Der Standortzugriff ist für Truck Wash deaktiviert. Wählen Sie eine Niederlassung manuell oder erlauben Sie den Zugriff in den iOS-Einstellungen.",
|
||||||
|
"unavailable": "Ihr Standort ist nicht verfügbar. Sie können weiterhin eine Niederlassung manuell auswählen.",
|
||||||
|
"timeout": "Die Standortbestimmung hat zu lange gedauert. Versuchen Sie es erneut oder wählen Sie eine Niederlassung manuell."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Use your location",
|
||||||
|
"explanation": "Use your location to find the nearest Truck Wash department.",
|
||||||
|
"use_current": "Use my location",
|
||||||
|
"checking": "Checking location...",
|
||||||
|
"denied": "Location is disabled for Truck Wash. Select a department manually or allow access in iOS Settings.",
|
||||||
|
"unavailable": "Your location is unavailable. You can still select a department manually.",
|
||||||
|
"timeout": "Finding your location took too long. Try again or select a department manually."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,15 @@
|
|||||||
|
{
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Bruk posisjonen din",
|
||||||
|
"explanation": "Bruk posisjonen din til å finne nærmeste Truck Wash-avdeling.",
|
||||||
|
"use_current": "Bruk posisjonen min",
|
||||||
|
"checking": "Kontrollerer posisjon...",
|
||||||
|
"denied": "Posisjon er slått av for Truck Wash. Velg en avdeling manuelt, eller gi tilgang i iOS-innstillingene.",
|
||||||
|
"unavailable": "Posisjonen din er ikke tilgjengelig. Du kan fortsatt velge en avdeling manuelt.",
|
||||||
|
"timeout": "Det tok for lang tid å finne posisjonen din. Prøv igjen, eller velg en avdeling manuelt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"phrases": {
|
||||||
|
"compat": {
|
||||||
|
"location_permission": {
|
||||||
|
"title": "Använd din plats",
|
||||||
|
"explanation": "Använd din plats för att hitta närmaste Truck Wash-avdelning.",
|
||||||
|
"use_current": "Använd min plats",
|
||||||
|
"checking": "Kontrollerar plats...",
|
||||||
|
"denied": "Platsåtkomst är avstängd för Truck Wash. Välj en avdelning manuellt eller tillåt åtkomst i iOS-inställningarna.",
|
||||||
|
"unavailable": "Din plats är inte tillgänglig. Du kan fortfarande välja en avdelning manuellt.",
|
||||||
|
"timeout": "Det tog för lång tid att hitta din plats. Försök igen eller välj en avdelning manuellt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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