Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#249): Adds default geopoint question type #300

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
38 changes: 38 additions & 0 deletions packages/common/src/fixtures/geopoint/1-geopoint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms"
xmlns:odk="http://www.opendatakit.org/xforms">
<h:head>
<h:title>Geopoint</h:title>
<model odk:xforms-version="1.0.0">
<itext>
<translation lang="English (en)">
<text id="/data/facility_gps:label">
<value>Collect the GPS coordinates of this facility.</value>
</text>
</translation>
<translation lang="French (fr)">
<text id="/data/facility_gps:label">
<value>Collectez les coordonnées GPS de cet établissement.</value>
</text>
</translation>
</itext>
<instance>
<data id="1_geopoint" version="2025020401">
<facility_gps/>
<meta>
<instanceID/>
</meta>
</data>
</instance>
<bind nodeset="/data/facility_gps" type="geopoint"/>
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
</model>
</h:head>
<h:body>
<input accuracyThreshold="5" unacceptableAccuracyThreshold="50" ref="/data/facility_gps">
<label ref="jr:itext('/data/facility_gps:label')"/>
</input>
</h:body>
</h:html>
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { computed, inject, provide, ref } from 'vue';
import ControlText from '../../ControlText.vue';
import ValidationMessage from '../../ValidationMessage.vue';
import InputDecimal from './InputDecimal.vue';
import InputGeopoint from './InputGeopoint.vue';
import InputInt from './InputInt.vue';
import InputNumbersAppearance from './InputNumbersAppearance.vue';
import InputText from './InputText.vue';
Expand Down Expand Up @@ -35,13 +36,19 @@ provide('isInvalid', isInvalid);
<template v-else-if="node.valueType === 'string' && node.appearances.numbers">
<InputNumbersAppearance :node="node" />
</template>
<template v-else-if="node.valueType === 'geopoint'">
<InputGeopoint :node="node" />
</template>
<template v-else>
<InputText :node="node" />
</template>

<i v-show="isInvalid && (doneAnswering || submitPressed)" class="icon-error" />
</div>
<ValidationMessage :message="node.validationState.violation?.message.asString" :show-message="doneAnswering || submitPressed" />
<ValidationMessage
:message="node.validationState.violation?.message.asString"
:show-message="doneAnswering || submitPressed"
/>
</template>

<style scoped lang="scss">
Expand Down
275 changes: 275 additions & 0 deletions packages/web-forms/src/components/controls/Input/InputGeopoint.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
<script setup lang="ts">
import type { GeopointInputNode } from '@getodk/xforms-engine';
import Button from 'primevue/button';
import PrimeProgressSpinner from 'primevue/progressspinner';
import { computed, ref } from 'vue';

interface Coordinates {
latitude: string;
longitude: string;
altitude: string;
accuracy: string;
}

interface InputGeopointProps {
readonly node: GeopointInputNode;
}

const props = defineProps<InputGeopointProps>();
const coords = ref<GeolocationCoordinates | null>(null);

const value = computed((): Coordinates | null => {
if (!props.node.currentState.value?.length) {
return null;
}

const [latitude, longitude, altitude, accuracy] = props.node.currentState.value.trim().split(' ');
return { latitude, longitude, altitude, accuracy };
});

/**
* Target in meters that can usually be reached by modern devices given enough time.
*/
const ACCURACY_THRESHOLD_DEFAULT = 5;
const accuracyThreshold = computed<number>(() => {
return props.node.nodeOptions.accuracyThreshold ?? ACCURACY_THRESHOLD_DEFAULT;
});

/**
* Target in meters, which is about the length of a city block.
*/
const UNACCEPTABLE_ACCURACY_THRESHOLD_DEFAULT = 100;
const unacceptableAccuracyThreshold = computed<number>(() => {
return (
props.node.nodeOptions.unacceptableAccuracyThreshold ?? UNACCEPTABLE_ACCURACY_THRESHOLD_DEFAULT
);
});

const qualityCoordinates = computed<string>(() => {
const accuracy = coords.value?.accuracy;
if (accuracy == null || accuracy > unacceptableAccuracyThreshold.value) {
// TODO: translations
return 'poor';
}

// TODO: translations
return 'good';
});

const watchID = ref<number | null>(null);
const isLocating = computed(() => {
return watchID.value !== null;
});

const start = () => {
if (watchID.value) {
stop();
}

watchID.value = navigator.geolocation.watchPosition(
(position) => {
coords.value = position.coords;

if (
value.value === null &&
accuracyThreshold.value !== 0 &&
coords.value.accuracy <= accuracyThreshold.value
) {
save();
}
},
() => {
// ToDo: do we show a modal with troubleshooting for the user?
},
{ enableHighAccuracy: true }
);
};

const stop = () => {
if (watchID.value === null) {
return;
}

navigator.geolocation.clearWatch(watchID.value);
watchID.value = null;
};

const save = () => {
stop();

if (!coords.value) {
return;
}

const { latitude, longitude, altitude, accuracy } = coords.value;
// ToDo: if one is missing, it still need to know the position of the others.
// ToDo: Change the value type to object? or default to negative / zero number?
props.node.setValue([latitude ?? 0, longitude ?? 0, altitude ?? 0, accuracy ?? 0].join(' '));
};

const formatNumber = (num: number) => {
const decimals = Number.isInteger(num) ? 0 : 2;
return num.toFixed(decimals);
};
</script>

<template>
<Button
v-if="value === null && !isLocating"
rounded
class="get-location-button"
@click="start()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="#5f6368"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zM7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 2.88-2.88 7.19-5 9.88C9.92 16.21 7 11.85 7 9z"
/>
<circle cx="12" cy="9" r="2.5" />
</svg>
<!-- TODO: translations -->
<span>Get location</span>
</Button>

<div v-else class="geolocation-container">
<div class="geolocation-result">
<div class="geolocation-icons">
<PrimeProgressSpinner class="spinner" stroke-width="4" />
</div>
<div class="geolocation-labels">
<!-- TODO: translations -->
<strong v-if="!coords?.accuracy">Getting location - please wait!</strong>
<strong v-else>{{ formatNumber(coords.accuracy) }}m - {{ qualityCoordinates }} accuracy</strong>
<p v-if="value === null && isLocating">
Location will be saved at {{ accuracyThreshold }}m
</p>
<p v-if="value?.accuracy != null">
Accuracy: {{ formatNumber(Number(value.accuracy)) }}m
</p>
<p v-if="value?.latitude != null">
Latitude: {{ value.latitude }}
</p>
<p v-if="value?.longitude != null">
Longitude: {{ value.longitude }}
</p>
<p v-if="value?.altitude != null">
Altitude: {{ value.altitude }}
</p>
</div>
</div>

<div class="geolocation-buttons">
<!-- TODO: translations -->
<Button v-if="isLocating" text rounded label="Cancel" @click="stop()" />

<!-- TODO: translations -->
<Button v-if="isLocating" label="Save location" rounded @click="save()" />

<Button
v-if="value !== null && !isLocating"
rounded
outlined
severity="contrast"
class="retry-button"
@click="start()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="15"
height="14"
viewBox="0 0 15 14"
fill="none"
>
<g clip-path="url(#clip0_1092_687)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.27039 5.96336C7.34312 5.99355 7.42115 6.00891 7.4999 6.00854C7.57865 6.00891 7.65668 5.99355 7.72941 5.96336C7.80214 5.93317 7.86811 5.88876 7.92345 5.83273L10.3209 3.43529C10.4331 3.32291 10.4962 3.17058 10.4962 3.01175C10.4962 2.85292 10.4331 2.70058 10.3209 2.5882L7.92345 0.190763C7.86858 0.131876 7.80241 0.0846451 7.72889 0.0518865C7.65536 0.019128 7.576 0.00151319 7.49552 9.32772e-05C7.41505 -0.00132663 7.33511 0.0134773 7.26048 0.0436218C7.18585 0.0737664 7.11805 0.118634 7.06114 0.175548C7.00422 0.232462 6.95936 0.300257 6.92921 0.374888C6.89907 0.449519 6.88426 0.529456 6.88568 0.609933C6.8871 0.690409 6.90472 0.769775 6.93748 0.843296C6.97023 0.916817 7.01747 0.982986 7.07635 1.03786L8.45091 2.41241H7.49986C5.96325 2.41241 4.48957 3.02283 3.40302 4.10938C2.31647 5.19593 1.70605 6.66961 1.70605 8.20622C1.70605 9.74283 2.31647 11.2165 3.40302 12.3031C4.48957 13.3896 5.96325 14 7.49986 14C9.03582 13.9979 10.5083 13.3868 11.5944 12.3007C12.6805 11.2146 13.2916 9.74218 13.2937 8.20622C13.2937 8.04726 13.2305 7.89481 13.1181 7.78241C13.0057 7.67001 12.8533 7.60686 12.6943 7.60686C12.5353 7.60686 12.3829 7.67001 12.2705 7.78241C12.1581 7.89481 12.0949 8.04726 12.0949 8.20622C12.0949 9.11504 11.8255 10.0035 11.3205 10.7591C10.8156 11.5148 10.098 12.1037 9.25832 12.4515C8.41868 12.7993 7.49476 12.8903 6.6034 12.713C5.71204 12.5357 4.89328 12.0981 4.25064 11.4554C3.60801 10.8128 3.17037 9.99404 2.99307 9.10268C2.81576 8.21132 2.90676 7.2874 3.25455 6.44776C3.60234 5.60811 4.19131 4.89046 4.94697 4.38554C5.70263 3.88063 6.59104 3.61113 7.49986 3.61113H8.45086L7.07635 4.98564C6.96411 5.09802 6.90107 5.25035 6.90107 5.40918C6.90107 5.56801 6.96411 5.72035 7.07635 5.83273C7.13169 5.88876 7.19766 5.93317 7.27039 5.96336Z"
fill="#64748B"
/>
</g>
<defs>
<clipPath id="clip0_1092_687">
<rect width="14" height="14" fill="white" transform="translate(0.5)" />
</clipPath>
</defs>
</svg>
<!-- TODO: translations -->
<span>Try again</span>
</Button>
</div>
</div>
</template>

<style scoped lang="scss">
@import 'primeflex/core/_variables.scss';

.geolocation-container {
display: flex;
flex-direction: column;
align-items: flex-end;
width: 50%;
background: var(--surface-50);
border: 1px solid var(--surface-200);
border-radius: 6px;
padding: 22px;
}

.get-location-button,
.retry-button {
display: inline-flex;
align-items: center;
justify-content: center;

svg {
margin-right: 5px;
}
}

.get-location-button {
min-width: 270px;
svg {
fill: var(--surface-0);
}
}

.retry-button svg path {
fill: var(--surface-900);
}

.geolocation-buttons button {
margin-left: 15px;
}

.geolocation-result {
display: flex;
width: 100%;
margin-bottom: 15px;
}

.geolocation-labels {
margin-top: 5px;

p {
font-size: 0.85rem;
}
}

.spinner {
width: 28px;
height: 28px;
margin-right: 15px;
}

@media screen and (max-width: #{$md}) {
.geolocation-container {
width: 100%;
}
}
</style>
Loading
Loading