From 2de418c3b42d0f8c1d6584d9ca1ac58e9486b9ec Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Wed, 28 Oct 2026 11:38:08 +0200 Subject: [PATCH 01/21] feat: time and date-time questions, numeric display logic and unknown question types - Answer time and date-time questions; date questions pick and keep a calendar day without a time zone shift. - Display logic compares number and rating answers as numbers. - A question type the app doesn't know shows a notice and never blocks the form. - Translations for the new placeholders and notice. --- .../(app)/form-questionnaire/[questionId].tsx | 13 +- .../(app)/form-submissions/[submissionId].tsx | 13 +- .../(app)/polling-station-questionnaire.tsx | 60 +++++++- mobile/app/citizen/main/form/index.tsx | 13 +- mobile/assets/locales/az/translations_AZ.json | 3 + mobile/assets/locales/bg/translations_BG.json | 3 + mobile/assets/locales/de/translations_DE.json | 3 + mobile/assets/locales/en/translations.json | 3 + mobile/assets/locales/es/translations_ES.json | 3 + mobile/assets/locales/hy/translations_HY.json | 3 + mobile/assets/locales/ka/translations_KA.json | 3 + mobile/assets/locales/pl/translations_PL.json | 3 + .../locales/pt-BR/translations_PT-BR.json | 3 + mobile/assets/locales/ro/translations.json | 3 + mobile/assets/locales/ru/translations_RU.json | 3 + mobile/assets/locales/sr/translations_SR.json | 3 + mobile/common/utils/answers.ts | 12 +- mobile/components/Inputs/DateInput.tsx | 36 +++-- mobile/components/QuestionForm.tsx | 42 +++++- mobile/services/form.parser.ts | 133 ++++++++++++++++-- mobile/services/interfaces/answer.type.ts | 38 ++++- mobile/services/interfaces/question.type.ts | 14 +- 22 files changed, 355 insertions(+), 55 deletions(-) diff --git a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx index c1db4bbfd..7b893a294 100644 --- a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx @@ -37,6 +37,7 @@ import { useCamera } from "../../../../hooks/useCamera"; import { AddAttachmentStartAPIPayload } from "../../../../services/api/add-attachment.api"; import { mapFormSubmissionDataToAPIFormSubmissionAnswer, + withAnswerTimestamps, removeAnswersForHiddenQuestions, setFormDefaultValues, shouldDisplayQuestion, @@ -160,10 +161,14 @@ const FormQuestionnaire = () => { if (activeElectionRound?.id && selectedPollingStation?.pollingStationId && activeQuestion) { // map the answer values - const updatedAnswer = mapFormSubmissionDataToAPIFormSubmissionAnswer( - questionId, - activeQuestion?.question.$questionType, - formValues[questionId], + // stamped with when it was first given and last changed (kept as is when unchanged) + const updatedAnswer = withAnswerTimestamps( + mapFormSubmissionDataToAPIFormSubmissionAnswer( + questionId, + activeQuestion?.question.$questionType, + formValues[questionId], + ), + answers?.[questionId], ); // Remove answers only for dependent questions that are no longer displayed diff --git a/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx b/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx index 0cd6a20cd..a942ab263 100644 --- a/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx +++ b/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx @@ -37,6 +37,7 @@ import { useCamera } from "../../../../hooks/useCamera"; import { AddAttachmentStartAPIPayload } from "../../../../services/api/add-attachment.api"; import { mapFormSubmissionDataToAPIFormSubmissionAnswer, + withAnswerTimestamps, setFormDefaultValues, shouldDisplayQuestion, } from "../../../../services/form.parser"; @@ -159,10 +160,14 @@ const FormSubmissions = () => { if (activeElectionRound?.id && selectedPollingStation?.pollingStationId && activeQuestion) { // map the answer values - const updatedAnswer = mapFormSubmissionDataToAPIFormSubmissionAnswer( - questionId, - activeQuestion?.question.$questionType, - formValues[questionId], + // stamped with when it was first given and last changed (kept as is when unchanged) + const updatedAnswer = withAnswerTimestamps( + mapFormSubmissionDataToAPIFormSubmissionAnswer( + questionId, + activeQuestion?.question.$questionType, + formValues[questionId], + ), + answers?.[questionId], ); const updatedAnswers = { diff --git a/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx b/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx index b6484ffe1..22213f438 100644 --- a/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx +++ b/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx @@ -25,8 +25,13 @@ import { Icon } from "../../../components/Icon"; import { useTranslation } from "react-i18next"; import Header from "../../../components/Header"; import { + fromDayIso, + fromTimeOfDay, mapAPIAnswersToFormAnswers, mapAPIQuestionsToFormQuestions, + toDayIso, + toTimeOfDay, + withAnswerTimestamps, } from "../../../services/form.parser"; import Button from "../../../components/Button"; import { useBottomInset } from "../../../hooks/useBottomInset"; @@ -120,7 +125,7 @@ const PollingStationQuestionnaire = () => { }; const onSubmit = (formData: Record>) => { - const answers: ApiFormAnswer[] = Object.keys(formData) + const updatedAnswers: ApiFormAnswer[] = Object.keys(formData) .map((questionId: string) => { const question: ApiFormQuestion = questions[questionId]; @@ -150,7 +155,19 @@ const PollingStationQuestionnaire = () => { return { $answerType: "dateAnswer", questionId, - date: new Date(formData[questionId] as string).toISOString(), + date: toDayIso(new Date(formData[questionId] as string)), + } as ApiFormAnswer; + case "timeAnswer": + return { + $answerType: "timeAnswer", + questionId, + time: toTimeOfDay(new Date(formData[questionId] as string)), + } as ApiFormAnswer; + case "dateTimeAnswer": + return { + $answerType: "dateTimeAnswer", + questionId, + dateTime: new Date(formData[questionId] as string).toISOString(), } as ApiFormAnswer; case "singleSelectAnswer": return { @@ -181,13 +198,20 @@ const PollingStationQuestionnaire = () => { return undefined; } }) + .map((answer) => answer && withAnswerTimestamps(answer, answers[answer.questionId])) .filter(Boolean) as ApiFormAnswer[]; + // Answers to questions this version can't show are kept as they are, not dropped on save. + const keptAnswers = Object.values(answers).filter((answer) => { + const question = questions[answer.questionId]; + return !!question && !(question.$questionType in FormQuestionAnswerTypeMapping); + }); + if (activeElectionRound?.id && selectedPollingStation?.pollingStationId) { mutate({ electionRoundId: activeElectionRound?.id, pollingStationId: selectedPollingStation?.pollingStationId, - answers, + answers: [...updatedAnswers, ...keptAnswers], lastUpdatedAt: new Date().toISOString(), }); router.back(); @@ -224,7 +248,13 @@ const PollingStationQuestionnaire = () => { acc[questionId] = answer?.value?.toString() ?? ""; break; case "dateAnswer": - acc[questionId] = answer.date ? new Date(answer.date) : ""; + acc[questionId] = answer.date ? fromDayIso(answer.date) : ""; + break; + case "timeAnswer": + acc[questionId] = answer.time ? fromTimeOfDay(answer.time) : ""; + break; + case "dateTimeAnswer": + acc[questionId] = answer.dateTime ? new Date(answer.dateTime) : ""; break; case "singleSelectAnswer": acc[questionId] = { @@ -378,7 +408,11 @@ const PollingStationQuestionnaire = () => { ); } - if (question.$questionType === "dateQuestion") { + if ( + question.$questionType === "dateQuestion" || + question.$questionType === "timeQuestion" || + question.$questionType === "dateTimeQuestion" + ) { return ( { render={({ field: { onChange, value } }) => ( { ); } - return ; + // A question type this version doesn't know: say so instead of an empty gap. + return ( + + + {t("form.unsupported_question", { ns: "polling_station_form_wizard" })} + + + ); })} diff --git a/mobile/app/citizen/main/form/index.tsx b/mobile/app/citizen/main/form/index.tsx index efb1cd0ba..2c46b9fbd 100644 --- a/mobile/app/citizen/main/form/index.tsx +++ b/mobile/app/citizen/main/form/index.tsx @@ -15,6 +15,7 @@ import { ApiFormQuestion } from "../../../../services/interfaces/question.type"; import { ApiFormAnswer } from "../../../../services/interfaces/answer.type"; import { mapFormSubmissionDataToAPIFormSubmissionAnswer, + withAnswerTimestamps, removeAnswersForHiddenQuestions, setFormDefaultValues, shouldDisplayQuestion, @@ -202,10 +203,14 @@ const CitizenForm = () => { if (activeQuestion) { // map the answer values - const updatedAnswer = mapFormSubmissionDataToAPIFormSubmissionAnswer( - questionId, - activeQuestion?.question.$questionType, - formValues[questionId], + // stamped with when it was first given and last changed (kept as is when unchanged) + const updatedAnswer = withAnswerTimestamps( + mapFormSubmissionDataToAPIFormSubmissionAnswer( + questionId, + activeQuestion?.question.$questionType, + formValues[questionId], + ), + answers?.[questionId], ); // Remove answers only for dependent questions that are no longer displayed diff --git a/mobile/assets/locales/az/translations_AZ.json b/mobile/assets/locales/az/translations_AZ.json index 3eda8e077..0903eaf40 100644 --- a/mobile/assets/locales/az/translations_AZ.json +++ b/mobile/assets/locales/az/translations_AZ.json @@ -483,6 +483,9 @@ "max": "Sütunun maksimum {{value}} simvolu olmalıdır", "required": "Bu sahə tələb olunur", "date_placeholder": "Xahiş edirik tarixi qeyd edin", + "time_placeholder": "Xahiş edirik vaxtı qeyd edin", + "datetime_placeholder": "Xahiş edirik tarixi və vaxtı qeyd edin", + "unsupported_question": "Bu sual tətbiqin daha yeni versiyasını tələb edir. Cavab vermək üçün tətbiqi yeniləyin.", "text_placeholder": "Xahiş edirik mətni qeyd edin" }, "save_and_continue": "Yadda saxlayın və davam edin", diff --git a/mobile/assets/locales/bg/translations_BG.json b/mobile/assets/locales/bg/translations_BG.json index e7a22158e..da4414b10 100644 --- a/mobile/assets/locales/bg/translations_BG.json +++ b/mobile/assets/locales/bg/translations_BG.json @@ -486,6 +486,9 @@ "max": "Полето трябва да съдържа максимум {{value}} знака", "required": "Това поле е задължително", "date_placeholder": "Моля, въведете дата", + "time_placeholder": "Моля, въведете час", + "datetime_placeholder": "Моля, въведете дата и час", + "unsupported_question": "Този въпрос изисква по-нова версия на приложението. Актуализирайте приложението, за да отговорите.", "text_placeholder": "Моля, въведете текст" }, "save_and_continue": "Запазете и продължете", diff --git a/mobile/assets/locales/de/translations_DE.json b/mobile/assets/locales/de/translations_DE.json index 1c4565556..26ed6812b 100644 --- a/mobile/assets/locales/de/translations_DE.json +++ b/mobile/assets/locales/de/translations_DE.json @@ -486,6 +486,9 @@ "max": "Das Feld darf maximal {{value}} Zeichen enthalten.", "required": "Dieses Feld ist ein Pflichtfeld.", "date_placeholder": "Bitte geben Sie ein Datum ein.", + "time_placeholder": "Bitte geben Sie eine Uhrzeit ein.", + "datetime_placeholder": "Bitte geben Sie Datum und Uhrzeit ein.", + "unsupported_question": "Für diese Frage ist eine neuere Version der App erforderlich. Aktualisieren Sie die App, um sie zu beantworten.", "text_placeholder": "Bitte geben Sie einen Text ein." }, "save_and_continue": "Speichern und fortfahren", diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 69a427ca3..545b19e73 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -486,6 +486,9 @@ "max": "The field must have a maximum of {{value}} characters", "required": "This field is required", "date_placeholder": "Please enter a date", + "time_placeholder": "Please enter a time", + "datetime_placeholder": "Please enter a date and time", + "unsupported_question": "This question needs a newer version of the app. Update the app to answer it.", "text_placeholder": "Please enter a text" }, "save_and_continue": "Save and continue", diff --git a/mobile/assets/locales/es/translations_ES.json b/mobile/assets/locales/es/translations_ES.json index 5a36fd974..1715c11d3 100644 --- a/mobile/assets/locales/es/translations_ES.json +++ b/mobile/assets/locales/es/translations_ES.json @@ -485,6 +485,9 @@ "max": "El campo debe tener un máximo de {{value}} caracteres", "required": "Este campo es obligatorio", "date_placeholder": "Por favor, ingresa una fecha", + "time_placeholder": "Por favor, ingresa una hora", + "datetime_placeholder": "Por favor, ingresa una fecha y hora", + "unsupported_question": "Esta pregunta requiere una versión más reciente de la aplicación. Actualiza la aplicación para responderla.", "text_placeholder": "Por favor, ingresa un texto" }, "save_and_continue": "Guardar y continuar", diff --git a/mobile/assets/locales/hy/translations_HY.json b/mobile/assets/locales/hy/translations_HY.json index c15e69cf8..6667d4b4d 100644 --- a/mobile/assets/locales/hy/translations_HY.json +++ b/mobile/assets/locales/hy/translations_HY.json @@ -486,6 +486,9 @@ "max": "Դաշտը պետք է պարունակի առավելագույնը {{value}} նիշ", "required": "Այս դաշտը պարտադիր է", "date_placeholder": "Խնդրում ենք մուտքագրել ամսաթիվը", + "time_placeholder": "Խնդրում ենք մուտքագրել ժամը", + "datetime_placeholder": "Խնդրում ենք մուտքագրել ամսաթիվը և ժամը", + "unsupported_question": "Այս հարցի համար անհրաժեշտ է հավելվածի ավելի նոր տարբերակ։ Թարմացրեք հավելվածը՝ պատասխանելու համար։", "text_placeholder": "Խնդրում ենք մուտքագրել տեքստը" }, "save_and_continue": "Պահպանել և շարունակել", diff --git a/mobile/assets/locales/ka/translations_KA.json b/mobile/assets/locales/ka/translations_KA.json index bb118fe70..875aa59e5 100644 --- a/mobile/assets/locales/ka/translations_KA.json +++ b/mobile/assets/locales/ka/translations_KA.json @@ -482,6 +482,9 @@ "max": "ველს უნდა ჰქონდეს მაქსიმუმ {{value}} სიმბოლო", "required": "ეს ველი სავალდებულოა", "date_placeholder": "გთხოვთ, შეიყვანოთ თარიღი", + "time_placeholder": "გთხოვთ, მიუთითეთ დრო", + "datetime_placeholder": "გთხოვთ, მიუთითეთ თარიღი და დრო", + "unsupported_question": "ამ კითხვისთვის საჭიროა აპლიკაციის უფრო ახალი ვერსია. პასუხის გასაცემად განაახლეთ აპლიკაცია.", "text_placeholder": "გთხოვთ, შეიყვანოთ ტექსტი" }, "save_and_continue": "შეინახეთ და გააგრძელეთ", diff --git a/mobile/assets/locales/pl/translations_PL.json b/mobile/assets/locales/pl/translations_PL.json index b5b1953a5..d2101e946 100644 --- a/mobile/assets/locales/pl/translations_PL.json +++ b/mobile/assets/locales/pl/translations_PL.json @@ -490,6 +490,9 @@ "max": "Pole musi zawierać maksymalnie {{value}} znaków", "required": "To pole jest wymagane", "date_placeholder": "Proszę wprowadzić datę", + "time_placeholder": "Proszę wprowadzić godzinę", + "datetime_placeholder": "Proszę wprowadzić datę i godzinę", + "unsupported_question": "To pytanie wymaga nowszej wersji aplikacji. Zaktualizuj aplikację, aby na nie odpowiedzieć.", "text_placeholder": "Proszę wpisać tekst" }, "save_and_continue": "Zapisz i kontynuuj", diff --git a/mobile/assets/locales/pt-BR/translations_PT-BR.json b/mobile/assets/locales/pt-BR/translations_PT-BR.json index 94d210759..58889d451 100644 --- a/mobile/assets/locales/pt-BR/translations_PT-BR.json +++ b/mobile/assets/locales/pt-BR/translations_PT-BR.json @@ -486,6 +486,9 @@ "max": "O campo deve ter um máximo de {{value}} caracteres", "required": "Este campo é obrigatório", "date_placeholder": "Por favor, insira uma data", + "time_placeholder": "Por favor, insira um horário", + "datetime_placeholder": "Por favor, insira uma data e um horário", + "unsupported_question": "Esta pergunta requer uma versão mais recente do aplicativo. Atualize o aplicativo para respondê-la.", "text_placeholder": "Por favor, insira um texto" }, "save_and_continue": "Salvar e continuar", diff --git a/mobile/assets/locales/ro/translations.json b/mobile/assets/locales/ro/translations.json index fa3a9f240..ef69175de 100644 --- a/mobile/assets/locales/ro/translations.json +++ b/mobile/assets/locales/ro/translations.json @@ -486,6 +486,9 @@ "max": "Câmpul trebuie să aibă maximum {{value}} caractere", "required": "Acest câmp este obligatoriu", "date_placeholder": "Te rugăm să introduci data", + "time_placeholder": "Te rugăm să introduci ora", + "datetime_placeholder": "Te rugăm să introduci data și ora", + "unsupported_question": "Această întrebare necesită o versiune mai nouă a aplicației. Actualizează aplicația ca să poți răspunde.", "text_placeholder": "Te rugăm să introduci un text" }, "save_and_continue": "Salvează și continuă", diff --git a/mobile/assets/locales/ru/translations_RU.json b/mobile/assets/locales/ru/translations_RU.json index 6c431cdd5..0f3615d99 100644 --- a/mobile/assets/locales/ru/translations_RU.json +++ b/mobile/assets/locales/ru/translations_RU.json @@ -483,6 +483,9 @@ "max": "Поле должно содержать максимум символов {{value}}", "required": "Это поле обязательно для заполнения", "date_placeholder": "Пожалуйста, введите дату:", + "time_placeholder": "Пожалуйста, введите время", + "datetime_placeholder": "Пожалуйста, введите дату и время", + "unsupported_question": "Для этого вопроса нужна более новая версия приложения. Обновите приложение, чтобы ответить.", "text_placeholder": "Пожалуйста, введите текст:" }, "save_and_continue": "Сохранить и продолжить", diff --git a/mobile/assets/locales/sr/translations_SR.json b/mobile/assets/locales/sr/translations_SR.json index 8d7bc2223..b894eb46b 100644 --- a/mobile/assets/locales/sr/translations_SR.json +++ b/mobile/assets/locales/sr/translations_SR.json @@ -483,6 +483,9 @@ "max": "Polje mora da sadrži najviše {{value}} karaktera", "required": "Ovo polje je obavezno", "date_placeholder": "Unesite datum", + "time_placeholder": "Unesite vreme", + "datetime_placeholder": "Unesite datum i vreme", + "unsupported_question": "Za ovo pitanje potrebna je novija verzija aplikacije. Ažurirajte aplikaciju da biste odgovorili.", "text_placeholder": "Unesite text" }, "save_and_continue": "Sačuvaj promene i nastavi.", diff --git a/mobile/common/utils/answers.ts b/mobile/common/utils/answers.ts index 23b635c95..7dc40ba68 100644 --- a/mobile/common/utils/answers.ts +++ b/mobile/common/utils/answers.ts @@ -6,7 +6,9 @@ import { ApiFormAnswer } from "../../services/interfaces/answer.type"; * * @param {ApiFormAnswer} answer - The ApiFormAnswer object to get the display string for. * depending on the answer type, the value has a different key - * type date -> date + * type date -> date (the day only) + * type time -> time ("HH:mm") + * type dateTime -> dateTime * type text -> text * type number -> value * type rating -> value @@ -20,8 +22,12 @@ export const getAnswerDisplay = (answer: ApiFormAnswer, displaySingleSelectOther switch (answer.$answerType) { case "dateAnswer": - const date = new Date(answer.date); // eslint-disable-line no-case-declarations - return format(date, "yyyy-MM-dd HH:mm"); + // a calendar day: shown as received, without a time or a time zone shift + return answer.date?.slice(0, 10) ?? ""; + case "timeAnswer": + return answer.time?.slice(0, 5) ?? ""; + case "dateTimeAnswer": + return answer.dateTime ? format(new Date(answer.dateTime), "yyyy-MM-dd HH:mm") : ""; case "textAnswer": return answer.text; case "singleSelectAnswer": diff --git a/mobile/components/Inputs/DateInput.tsx b/mobile/components/Inputs/DateInput.tsx index a71241843..c6667a42c 100644 --- a/mobile/components/Inputs/DateInput.tsx +++ b/mobile/components/Inputs/DateInput.tsx @@ -12,8 +12,12 @@ import { useLocalSearchParams } from "expo-router"; import { useTranslation } from "react-i18next"; import useAnimatedKeyboardPadding from "../../hooks/useAnimatedKeyboardPadding"; +export type DateInputMode = "date" | "time" | "datetime"; + export interface DateInputProps extends XStackProps { value: Date | null; + // what is picked: a calendar day, a time of day, or both (the default) + mode?: DateInputMode; onChange: (...event: any[]) => void; minimumDate?: Date | null; maximumDate?: Date | null; @@ -21,6 +25,17 @@ export interface DateInputProps extends XStackProps { error?: string; } +const formatDay = (value: Date) => + value.toLocaleDateString(["en-GB"], { month: "2-digit", day: "2-digit", year: "numeric" }); +const formatTime = (value: Date) => + value.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + +const formatValue = (value: Date, mode: DateInputMode) => { + if (mode === "date") return formatDay(value); + if (mode === "time") return formatTime(value); + return `${formatDay(value)} - ${formatTime(value)}`; +}; + export const DateInput: React.FC = ({ value, onChange, @@ -29,6 +44,7 @@ export const DateInput: React.FC = ({ placeholder, disabled, error, + mode = "datetime", ...rest }) => { const localParams = useLocalSearchParams(); @@ -49,6 +65,11 @@ export const DateInput: React.FC = ({ } else { if (event.type === "set") { onClose(); + // android picks one part per dialog: a date or time question is done after its own dialog + if (mode !== "datetime") { + selectedDate && onChange(selectedDate); + return; + } DateTimePickerAndroid.open({ mode: "time", value: value || new Date(), @@ -100,16 +121,7 @@ export const DateInput: React.FC = ({ numberOfLines={1} width="90%" > - {value - ? `${value.toLocaleDateString(["en-GB"], { - month: "2-digit", - day: "2-digit", - year: "numeric", - })} - ${value.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })}` - : placeholder} + {value ? formatValue(value, mode) : placeholder} {/* open bottom sheet on ios with date picker */} @@ -122,7 +134,7 @@ export const DateInput: React.FC = ({ = ({ // open date picker modal on android open && DateTimePickerAndroid.open({ - mode: "date", + mode: mode === "time" ? "time" : "date", value: value || new Date(), onChange: onDateChange, is24Hour: true, diff --git a/mobile/components/QuestionForm.tsx b/mobile/components/QuestionForm.tsx index 6bb395bca..cc4dd3569 100644 --- a/mobile/components/QuestionForm.tsx +++ b/mobile/components/QuestionForm.tsx @@ -11,6 +11,7 @@ import WizardRatingFormInput from "./WizardFormInputs/WizardRatingFormInput"; import { useTranslation } from "react-i18next"; import { ApiFormQuestion } from "../services/interfaces/question.type"; +import { FormQuestionAnswerTypeMapping } from "../services/interfaces/answer.type"; import { Typography } from "./Typography"; interface IQuestionFormProps { @@ -39,6 +40,9 @@ const QuestionForm = ({ const { t } = useTranslation("polling_station_form_wizard"); let returnedComponent: React.ReactNode; + // A question type this version of the app doesn't know can't be answered, so it must never block the form. + const supported = + !!activeQuestion && activeQuestion.question.$questionType in FormQuestionAnswerTypeMapping; return ( <> @@ -46,7 +50,7 @@ const QuestionForm = ({ key={activeQuestion?.question?.id} name={activeQuestion?.question?.id} rules={{ - required: required ? { value: true, message: t("form.required") } : false, + required: required && supported ? { value: true, message: t("form.required") } : false, }} control={control} render={({ field: { value, onChange }, fieldState: { error } }) => { @@ -91,6 +95,7 @@ const QuestionForm = ({ case "dateQuestion": returnedComponent = ( ); break; + case "timeQuestion": + returnedComponent = ( + + ); + break; + case "dateTimeQuestion": + returnedComponent = ( + + ); + break; case "singleSelectQuestion": returnedComponent = ( <> @@ -215,6 +244,17 @@ const QuestionForm = ({ /> ); break; + default: { + const unknown: ApiFormQuestion = activeQuestion.question; + returnedComponent = ( + + {t("form.unsupported_question")} + + ); + } } return ( diff --git a/mobile/services/form.parser.ts b/mobile/services/form.parser.ts index da5933ea5..2ef72ee2b 100644 --- a/mobile/services/form.parser.ts +++ b/mobile/services/form.parser.ts @@ -21,10 +21,15 @@ import { } from "./interfaces/question.type"; const mapDisplayConditionToMath = ( - operand1: string, - operand2: string, + answer: string | number, + target: string, condition: DisplayLogicCondition, ) => { + // numbers and ratings compare as numbers ("10" > "9"); text compares as text + const numeric = typeof answer === "number"; + const operand1 = answer; + const operand2 = numeric ? Number(target) : target; + if (numeric && Number.isNaN(operand2)) return false; switch (condition) { case "Equals": return operand1 === operand2; @@ -43,6 +48,91 @@ const mapDisplayConditionToMath = ( } }; +const pad = (n: number) => String(n).padStart(2, "0"); + +/** The picked local day as "YYYY-MM-DDT00:00:00Z": the API keeps the date part as is, with no time zone shift. */ +export const toDayIso = (date: Date) => + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T00:00:00Z`; + +/** A date answer back to a local Date on that day (only the first 10 characters count). */ +export const fromDayIso = (value: string) => { + const [year, month, day] = value.slice(0, 10).split("-").map(Number); + return new Date(year, month - 1, day); +}; + +/** The picked local time as "HH:mm:ss" (a time of day, without a date or time zone). */ +export const toTimeOfDay = (date: Date) => `${pad(date.getHours())}:${pad(date.getMinutes())}:00`; + +/** A time answer back to a Date today at that time, for the picker. */ +export const fromTimeOfDay = (value: string) => { + const [hours, minutes] = value.split(":").map(Number); + const date = new Date(); + date.setHours(hours || 0, minutes || 0, 0, 0); + return date; +}; + +/** + * Whether a select parent's picked options meet a display rule. The rule lists the options in + * `optionIds`: "AnyOf" needs one of them picked, "All" every one of them. + * A condition this version doesn't know shows the question, so its answer is never discarded. + */ +const matchesSelectCondition = ( + picked: string[], + logic: { condition: DisplayLogicCondition; optionIds?: string[] | null }, +) => { + const targets = logic.optionIds ?? []; + switch (logic.condition) { + case "AnyOf": + return picked.some((id) => targets.includes(id)); + case "All": + return targets.length > 0 && targets.every((id) => picked.includes(id)); + default: + return true; + } +}; + +/** What an answer says, for comparing two answers (ignores stamps and display-only fields). */ +const answerValueKey = (answer: ApiFormAnswer): string => { + switch (answer.$answerType) { + case "textAnswer": + return `text:${answer.text}`; + case "numberAnswer": + case "ratingAnswer": + return `${answer.$answerType}:${answer.value}`; + case "dateAnswer": + return `date:${answer.date?.slice(0, 10)}`; + case "timeAnswer": + return `time:${answer.time?.slice(0, 5)}`; + case "dateTimeAnswer": + return `dateTime:${new Date(answer.dateTime).getTime()}`; + case "singleSelectAnswer": + return `single:${answer.selection?.optionId}:${answer.selection?.text ?? ""}`; + case "multiSelectAnswer": + return `multi:${(answer.selection ?? []) + .map((option) => `${option.optionId}:${option.text ?? ""}`) + .sort() + .join("|")}`; + default: + return JSON.stringify(answer); + } +}; + +/** + * Stamps an answer with when it was first given and last changed. An unchanged answer keeps its + * stamps, so re-saving a form doesn't move them; a changed one keeps its createdAt. + */ +export const withAnswerTimestamps = ( + next: ApiFormAnswer | undefined, + previous: ApiFormAnswer | undefined, + now: string = new Date().toISOString(), +): ApiFormAnswer | undefined => { + if (!next) return undefined; + if (previous && answerValueKey(previous) === answerValueKey(next)) { + return { ...next, createdAt: previous.createdAt, lastUpdatedAt: previous.lastUpdatedAt }; + } + return { ...next, createdAt: previous?.createdAt ?? now, lastUpdatedAt: now }; +}; + export const shouldDisplayQuestion = ( q: ApiFormQuestion, answers: Record | undefined, @@ -54,26 +144,31 @@ export const shouldDisplayQuestion = ( const condition: DisplayLogicCondition = q.displayLogic.condition; const parentAnswerType = answers?.[q.displayLogic?.parentQuestionId]?.$answerType; const parentAnswer = answers?.[q.displayLogic?.parentQuestionId]; - const value = q.displayLogic?.value; + const value = q.displayLogic?.value ?? ""; + const optionIds = q.displayLogic?.optionIds ?? []; let shouldDisplay = false; - if (!condition || !parentAnswerType || !value) { + if (!condition || !parentAnswerType || (!value && optionIds.length === 0)) { return shouldDisplay; } switch (parentAnswerType) { case "multiSelectAnswer": - shouldDisplay = !!(parentAnswer as MultiSelectAnswer).selection.find( - (option) => option.optionId === value, + shouldDisplay = matchesSelectCondition( + (parentAnswer as MultiSelectAnswer).selection.map((option) => option.optionId), + q.displayLogic, ); break; case "singleSelectAnswer": - shouldDisplay = (parentAnswer as SingleSelectAnswer).selection.optionId === value; + shouldDisplay = matchesSelectCondition( + [(parentAnswer as SingleSelectAnswer).selection.optionId], + q.displayLogic, + ); break; case "numberAnswer": shouldDisplay = mapDisplayConditionToMath( - (parentAnswer as NumberAnswer).value.toString(), + (parentAnswer as NumberAnswer).value, value, condition, ); @@ -87,7 +182,7 @@ export const shouldDisplayQuestion = ( break; case "ratingAnswer": shouldDisplay = mapDisplayConditionToMath( - (parentAnswer as RatingAnswer).value.toString(), + (parentAnswer as RatingAnswer).value, value, condition, ); @@ -175,7 +270,19 @@ export const mapFormSubmissionDataToAPIFormSubmissionAnswer = ( return { $answerType: "dateAnswer", questionId, - date: new Date(answer as string).toISOString(), + date: toDayIso(new Date(answer as string)), + } as ApiFormAnswer; + case "timeAnswer": + return { + $answerType: "timeAnswer", + questionId, + time: toTimeOfDay(new Date(answer as string)), + } as ApiFormAnswer; + case "dateTimeAnswer": + return { + $answerType: "dateTimeAnswer", + questionId, + dateTime: new Date(answer as string).toISOString(), } as ApiFormAnswer; case "singleSelectAnswer": return { @@ -217,7 +324,11 @@ export const setFormDefaultValues = (questionId: string, answer?: ApiFormAnswer) case "ratingAnswer": return { [questionId]: answer?.value?.toString() ?? "" }; case "dateAnswer": - return { [questionId]: answer.date ? new Date(answer.date) : "" }; + return { [questionId]: answer.date ? fromDayIso(answer.date) : "" }; + case "timeAnswer": + return { [questionId]: answer.time ? fromTimeOfDay(answer.time) : "" }; + case "dateTimeAnswer": + return { [questionId]: answer.dateTime ? new Date(answer.dateTime) : "" }; case "singleSelectAnswer": return { [questionId]: { diff --git a/mobile/services/interfaces/answer.type.ts b/mobile/services/interfaces/answer.type.ts index 298783b44..abc42adb6 100644 --- a/mobile/services/interfaces/answer.type.ts +++ b/mobile/services/interfaces/answer.type.ts @@ -4,6 +4,8 @@ export type ApiFormAnswerType = | "textAnswer" | "numberAnswer" | "dateAnswer" + | "timeAnswer" + | "dateTimeAnswer" | "singleSelectAnswer" | "multiSelectAnswer" | "ratingAnswer"; @@ -13,34 +15,54 @@ export type ApiFormAnswer = | NumberAnswer | RatingAnswer | DateAnswer + | TimeAnswer + | DateTimeAnswer | SingleSelectAnswer | MultiSelectAnswer; -export type TextAnswer = { +// When the answer was first given and last changed on the device (ISO). Optional: older answers have neither. +type AnswerTimestamps = { + createdAt?: string; + lastUpdatedAt?: string; +}; + +export type TextAnswer = AnswerTimestamps & { questionId: string; $answerType: "textAnswer"; text: string; }; -export type NumberAnswer = { +export type NumberAnswer = AnswerTimestamps & { questionId: string; $answerType: "numberAnswer"; value: number; }; -export type RatingAnswer = { +export type RatingAnswer = AnswerTimestamps & { questionId: string; $answerType: "ratingAnswer"; value: number; }; -export type DateAnswer = { +export type DateAnswer = AnswerTimestamps & { questionId: string; $answerType: "dateAnswer"; - date: string; // ISO String + date: string; // the picked day as "YYYY-MM-DDT00:00:00Z"; only the date part counts +}; + +export type TimeAnswer = AnswerTimestamps & { + questionId: string; + $answerType: "timeAnswer"; + time: string; // "HH:mm:ss", a wall-clock time without a date or time zone +}; + +export type DateTimeAnswer = AnswerTimestamps & { + questionId: string; + $answerType: "dateTimeAnswer"; + dateTime: string; // ISO String }; -export type SingleSelectAnswer = { +export type SingleSelectAnswer = AnswerTimestamps & { questionId: string; $answerType: "singleSelectAnswer"; selection: { @@ -50,7 +72,7 @@ export type SingleSelectAnswer = { }; }; -export type MultiSelectAnswer = { +export type MultiSelectAnswer = AnswerTimestamps & { questionId: string; $answerType: "multiSelectAnswer"; selection: { @@ -64,6 +86,8 @@ export const FormQuestionAnswerTypeMapping: Record Date: Wed, 28 Oct 2026 11:38:51 +0200 Subject: [PATCH 02/21] fix: keep older select display rules that name one option in value --- mobile/services/form.parser.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/services/form.parser.ts b/mobile/services/form.parser.ts index 2ef72ee2b..fce124c2c 100644 --- a/mobile/services/form.parser.ts +++ b/mobile/services/form.parser.ts @@ -78,7 +78,7 @@ export const fromTimeOfDay = (value: string) => { */ const matchesSelectCondition = ( picked: string[], - logic: { condition: DisplayLogicCondition; optionIds?: string[] | null }, + logic: { condition: DisplayLogicCondition; value?: string | null; optionIds?: string[] | null }, ) => { const targets = logic.optionIds ?? []; switch (logic.condition) { @@ -87,7 +87,8 @@ const matchesSelectCondition = ( case "All": return targets.length > 0 && targets.every((id) => picked.includes(id)); default: - return true; + // older rules name a single option in value: shown when that option is picked + return !!logic.value && picked.includes(logic.value); } }; From 3fe01b6fcaf106df5053055fcf9c1ecdcfef3e75 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Wed, 28 Oct 2026 11:44:54 +0200 Subject: [PATCH 03/21] fix: show the splash screen logo at a readable size The legacy splash config shrank the whole full-screen image into the Android 12+ splash icon, leaving the logo about 40dp wide. Use the expo-splash-screen plugin with a logo-only image: 190dp on Android (the widest that stays inside the splash icon's visible circle) and 280pt on iOS. --- mobile/app.json | 17 +++++++--- mobile/assets/icons/splash-logo.png | Bin 0 -> 25755 bytes mobile/package-lock.json | 50 ++++++++++++++++++++++++++-- mobile/package.json | 1 + 4 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 mobile/assets/icons/splash-logo.png diff --git a/mobile/app.json b/mobile/app.json index 83f6a6057..fa7e79304 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -7,11 +7,6 @@ "orientation": "portrait", "icon": "./assets/icons/icon.png", "userInterfaceStyle": "light", - "splash": { - "image": "./assets/icons/splash.png", - "resizeMode": "contain", - "backgroundColor": "#FDD20C" - }, "assetBundlePatterns": [ "**/*" ], @@ -63,6 +58,18 @@ }, "plugins": [ "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/icons/splash-logo.png", + "resizeMode": "contain", + "backgroundColor": "#FDD20C", + "imageWidth": 190, + "ios": { + "imageWidth": 280 + } + } + ], [ "expo-font", { diff --git a/mobile/assets/icons/splash-logo.png b/mobile/assets/icons/splash-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5fbef30fc806160d38dcbd61b953f6468ace2745 GIT binary patch literal 25755 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 z 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 N>UwHVcO1R={{RAbu&Mw6 literal 0 HcmV?d00001 diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 58917d774..49ff0a535 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -47,6 +47,7 @@ "expo-notifications": "~0.32.17", "expo-router": "~6.0.24", "expo-secure-store": "~15.0.8", + "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-updates": "~29.0.19", @@ -144,6 +145,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1546,6 +1548,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", "license": "MIT", + "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -3757,7 +3760,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@jsamr/counter-style/-/counter-style-2.0.2.tgz", "integrity": "sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jsamr/react-native-li": { "version": "2.3.1", @@ -4896,6 +4900,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.13.4.tgz", "integrity": "sha512-68CIOavWFDt6rUPEo9ELap5e3I09iFMWQVXNPANVrrCEHhjegwaNfZTwaSTQN43IbATFsgkOON4T7Hh/skq9dA==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/elements": "^2.9.35", "color": "^4.2.3", @@ -4940,6 +4945,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.13.tgz", "integrity": "sha512-eiNUieNCJxCdHSVBUJWwoACspqlUGULHj36Z8CQRlymwPxOD8fXA23vXAqdWJ6VT3MmVh7akLLPvs9aKMArgxg==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.21.10", "escape-string-regexp": "^4.0.0", @@ -5497,6 +5503,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -7144,6 +7151,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.101.4" }, @@ -7288,6 +7296,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7297,7 +7306,6 @@ "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", "license": "MIT", - "peer": true, "dependencies": { "@react-native/virtualized-lists": "^0.72.4", "@types/react": "*" @@ -7308,7 +7316,6 @@ "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", "license": "MIT", - "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -7384,6 +7391,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -7625,6 +7633,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8488,6 +8497,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -9968,6 +9978,7 @@ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10049,6 +10060,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10249,6 +10261,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10330,6 +10343,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -10418,6 +10432,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -10728,6 +10743,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.36.tgz", "integrity": "sha512-HMHp1H+actmnX85NJE6lILKzSJV6pTDNkwghq9EMOP3zTynjvBYVqJGSLlm6sEVzJCC5Z2ZiKgLvtsHrqlY0dg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.26", @@ -10862,6 +10878,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" @@ -10998,6 +11015,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz", "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -11077,6 +11095,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.13", "invariant": "^2.2.4" @@ -11314,6 +11333,18 @@ "node": ">=20.16.0" } }, + "node_modules/expo-splash-screen": { + "version": "31.0.13", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", + "integrity": "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==", + "license": "MIT", + "dependencies": { + "@expo/prebuild-config": "^54.0.8" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", @@ -12505,6 +12536,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" } @@ -16307,6 +16339,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16347,6 +16380,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -16377,6 +16411,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz", "integrity": "sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -16421,6 +16456,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -16510,6 +16546,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -16591,6 +16628,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "^7.7.2" @@ -16639,6 +16677,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -16649,6 +16688,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -16664,6 +16704,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -16853,6 +16894,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16932,6 +16974,7 @@ "integrity": "sha512-mOzkc1Rb4sN98OLY/frkP0e1A60bTFjuXUgG8D4m+pwCcjVCWZupdSFV8c5+gqcZBnreoVR20/ko6D8X/7KPvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "reactotron-core-contract": "0.4.0" } @@ -18865,6 +18908,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/mobile/package.json b/mobile/package.json index 439179957..7f6351593 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -61,6 +61,7 @@ "expo-notifications": "~0.32.17", "expo-router": "~6.0.24", "expo-secure-store": "~15.0.8", + "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-updates": "~29.0.19", From 715ed1575e3c33495fc727fcb0e4138bf5efba61 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Wed, 28 Oct 2026 11:46:43 +0200 Subject: [PATCH 04/21] fix: back after deleting a submission no longer returns to its delete preview Deleting pushed the form's submissions screen on top of the delete preview, so back went to the preview of the deleted submission. Go back to the submissions screen it was opened from instead. --- .../(observation)/delete-submission/[submissionId].tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx index 8f5de013d..27c03ac56 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx @@ -254,7 +254,13 @@ const DeleteFormSubmission = () => { { onSuccess: () => { setShowWarningModal(false); - router.push(`/multi-submission-form-details/${formId}?language=${language}`); + // leave this preview: back to the form's submissions it was opened from, so back from there doesn't + // return to a deleted submission + if (router.canGoBack()) { + router.back(); + } else { + router.replace(`/multi-submission-form-details/${formId}?language=${language}`); + } }, }, ); From d3d59ba02710bee664c5d2b91e7e00458f2713f6 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Mon, 2 Nov 2026 10:11:22 +0200 Subject: [PATCH 05/21] Mobile: scroll the answers on the delete submission screen and pin the delete button Long forms pushed the answers and the Delete button below the screen with no way to scroll, so observers couldn't confirm the deletion. --- .../delete-submission/[submissionId].tsx | 92 ++++++++++--------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx index 27c03ac56..4b37547a0 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx @@ -2,7 +2,6 @@ import { router, useLocalSearchParams } from "expo-router"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScrollView, Separator, Spinner, YStack } from "tamagui"; import { getAnswerDisplay } from "../../../../../../../common/utils/answers"; import Button from "../../../../../../../components/Button"; @@ -46,7 +45,6 @@ const DeleteFormSubmission = () => { const { activeElectionRound, selectedPollingStation } = useUserData(); const [isRefreshing, setIsRefreshing] = useState(false); const [showWarningModal, setShowWarningModal] = useState(false); - const insets = useSafeAreaInsets(); const { mutate: deleteSubmission } = useDeleteFormSubmissionMutation({ electionRoundId: activeElectionRound?.id, @@ -315,48 +313,60 @@ const DeleteFormSubmission = () => { leftIcon={} onLeftPress={() => router.back()} /> - - - {formTitle} - {displayedQuestions.map((question) => ( - - - {question.code} - {question.text[language]} + {/* The answers scroll; the delete button stays pinned under them so it is always reachable. */} + } + > + {formTitle} + {displayedQuestions.map((question) => ( + + + {question.code} - {question.text[language]} + + {mappedAnswers && mappedAnswers[question.id] && ( + + {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} - {mappedAnswers && mappedAnswers[question.id] && ( - - {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} + )} + {attachments && attachments[question.id] ? ( + + + {t("attachments", { value: attachments[question.id].length })} - )} - {attachments && attachments[question.id] ? ( - - - {t("attachments", { value: attachments[question.id].length })} - - - ) : ( - false - )} - - {notes && notes[question.id] ? ( - - - {t("notes", { value: notes[question.id].length })} - - - ) : ( - false - )} - - - ))} - - - + + ) : ( + false + )} + + {notes && notes[question.id] ? ( + + + {t("notes", { value: notes[question.id].length })} + + + ) : ( + false + )} + - + ))} + + + + {showWarningModal && ( From 64febdbdd496a9d057158663547091ff2759c713 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Mon, 2 Nov 2026 16:31:56 +0200 Subject: [PATCH 06/21] Mobile: fix the Android crash after saving arrival and departure time Saving updated the polling station information card on the home screen while Android was still re-attaching it during the back transition, which crashed with "addViewAt: failed to insert view". The home screen now shows changes to that card only once its transition has ended. --- .../(drawer)/(tabs)/(observation)/index.tsx | 8 +++- mobile/hooks/useSettledValue.ts | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 mobile/hooks/useSettledValue.ts diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx index 8e410ed06..6cab38b41 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx @@ -19,6 +19,7 @@ import SingleSubmissionFormList from "../../../../../../components/SingleSubmiss import ObservationSkeleton from "../../../../../../components/SkeletonLoaders/ObservationSkeleton"; import { Typography } from "../../../../../../components/Typography"; import { useUserData } from "../../../../../../contexts/user/UserContext.provider"; +import { useSettledValue } from "../../../../../../hooks/useSettledValue"; import { usePollingStationInformation, usePollingStationInformationForm, @@ -36,6 +37,9 @@ const Index = () => { activeElectionRound?.id, selectedPollingStation?.pollingStationId, ); + // saving arrival / departure time or the PSI form updates this data while the screen comes back into view; + // showing the change only once it's settled avoids an Android crash (see useSettledValue) + const shownPsiData = useSettledValue(psiData); const { data: psiFormQuestions, isLoading: isLoadingPsiFormQuestions } = usePollingStationInformationForm(activeElectionRound?.id); @@ -192,7 +196,7 @@ const Index = () => { - + {t("forms.heading")} @@ -207,7 +211,7 @@ const Index = () => { - + {t("forms.heading")} diff --git a/mobile/hooks/useSettledValue.ts b/mobile/hooks/useSettledValue.ts new file mode 100644 index 000000000..da63f626b --- /dev/null +++ b/mobile/hooks/useSettledValue.ts @@ -0,0 +1,40 @@ +import { useCallback, useRef, useState } from "react"; +import { useFocusEffect, useNavigation } from "expo-router"; + +// how long to wait for the stack's transitionEnd before showing the latest value anyway +const FALLBACK_MS = 1000; + +/** + * Returns `value` as it was when this screen was last settled: while another screen covers it, and until the + * transition that reveals it again has ended, the previous value is kept. + * + * On Android (Fabric), changing the shape of a screen's view tree while react-native-screens re-attaches it during a + * pop crashes the app with "addViewAt: failed to insert view". That happens when the screen on top saves and goes + * back: the save updates the query cache this screen renders (optimistic update, then the response and the refetch) + * in the middle of the animation. Holding the value back until the screen is settled moves that change after it. + */ +export function useSettledValue(value: T): T { + const navigation = useNavigation(); + const [settled, setSettled] = useState(true); + const shown = useRef(value); + + useFocusEffect( + useCallback(() => { + // focus comes when the pop starts; the transition events belong to the route in the enclosing stack (this + // screen can sit inside tabs and a drawer) + let stack: any = navigation; + while (stack && stack.getState?.()?.type !== "stack") stack = stack.getParent?.(); + const settle = () => setSettled(true); + const timer = setTimeout(settle, FALLBACK_MS); + const unsubscribe = stack?.addListener?.("transitionEnd", settle); + return () => { + clearTimeout(timer); + unsubscribe?.(); + setSettled(false); + }; + }, [navigation]), + ); + + if (settled) shown.current = value; + return shown.current; +} From 5b863e835558c99553d578adc7cfeb210c283eba Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Mon, 2 Nov 2026 16:42:13 +0200 Subject: [PATCH 07/21] Mobile: stop reporting 404s, timeouts and network errors to Sentry They are expected on observers' phones and buried the real errors. One check, isExpectedApiError, drops them in the API error reporter and in Sentry's beforeSend, and the polling station and citizen form loaders no longer send their own messages for them. The [API FAILED] console log, which Sentry kept as a breadcrumb with the full stack, now prints only in development. --- mobile/app/_layout.tsx | 3 ++ mobile/services/api.ts | 39 +++++++++++++++--------- mobile/services/queries.service.ts | 11 ++++--- mobile/services/queries/citizen.query.ts | 11 ++++--- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 7ff1c4692..b548a2e09 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/react-native"; +import { isExpectedApiError } from "../services/api"; import { TamaguiProvider } from "@tamagui/core"; import { useFonts } from "expo-font"; import * as Notifications from "expo-notifications"; @@ -50,6 +51,8 @@ Sentry.init({ // We recommend adjusting this value in production tracesSampleRate: process.env.EXPO_PUBLIC_ENVIRONMENT === "production" ? 0.2 : 0, integrations: [Sentry.reactNativeTracingIntegration()], + // 404s, timeouts and lost connectivity are expected (see isExpectedApiError) + beforeSend: (event, hint) => (isExpectedApiError(hint.originalException) ? null : event), }); SplashScreen.preventAutoHideAsync(); diff --git a/mobile/services/api.ts b/mobile/services/api.ts index 9997236c8..32a1ba850 100644 --- a/mobile/services/api.ts +++ b/mobile/services/api.ts @@ -151,7 +151,8 @@ API.interceptors.response.use( return handleTokenRefresh(originalRequest); } - console.log("❌ [API FAILED] Error", error); + // console output becomes a Sentry breadcrumb in release builds: keep it to development + if (__DEV__) console.log("❌ [API FAILED] Error", error); reportApiError(error); throw error; @@ -164,6 +165,20 @@ const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} const normalizeUrl = (url?: string) => (url ?? "").split("?")[0].replace(UUID_REGEX, "{id}").replace(/^\//, ""); +/** + * Not found (e.g. a round without citizen reporting forms), timeouts and requests without a response (no + * connectivity) are expected on observers' phones, and the queries retry them: reporting each one only buries real + * errors. Sentry's beforeSend drops them wherever they're captured. + */ +export const isExpectedApiError = (error: unknown) => { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + return status === 404 || (!status && !!error.request); + } + // fetch (uploads, push tokens) without connectivity + return error instanceof TypeError && error.message === "Network request failed"; +}; + const reportApiError = (error: AxiosError) => { if (axios.isCancel(error)) { return; @@ -178,24 +193,20 @@ const reportApiError = (error: AxiosError) => { return; } - let title: string; - let level: Sentry.SeverityLevel; - - if (status) { - // The server responded with an error status - title = `API ${status} ${method} ${url}`; - level = status >= 500 ? "error" : "warning"; - } else if (error.request) { - // No response: timeout or no connectivity - const isTimeout = error.code === "ECONNABORTED" || error.code === "ETIMEDOUT"; - title = `API ${isTimeout ? "timeout" : "network error"} ${method} ${url}`; - level = "warning"; - } else { + if (isExpectedApiError(error)) { + return; + } + + if (!status) { // The request could not be created Sentry.captureException(error); return; } + // The server responded with an error status + const title = `API ${status} ${method} ${url}`; + const level: Sentry.SeverityLevel = status >= 500 ? "error" : "warning"; + const apiError = new Error(title); apiError.name = "ApiError"; diff --git a/mobile/services/queries.service.ts b/mobile/services/queries.service.ts index b2669b2fc..fb7dae47e 100644 --- a/mobile/services/queries.service.ts +++ b/mobile/services/queries.service.ts @@ -12,6 +12,7 @@ import * as DB from "../database/DAO/PollingStationsNomenclatorDAO"; import { PollingStationNomenclatorNodeVM } from "../common/models/polling-station.model"; import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Sentry from "@sentry/react-native"; +import { isExpectedApiError } from "./api"; import { exists } from "i18next"; export const electionRoundsKeys = { @@ -174,10 +175,12 @@ export const usePollingStationsNomenclatorQuery = (electionRoundId: string | und } } catch (err) { console.warn("usePollingStationsNomenclatorQuery", err); - Sentry.captureMessage( - `Failed to save nomenclator. Exists: ${exists}, LocalVersionKey: ${localVersionKey}, ServerVersionKey: ${serverVersionKey}`, - ); - Sentry.captureException(err); + if (!isExpectedApiError(err)) { + Sentry.captureMessage( + `Failed to save nomenclator. Exists: ${exists}, LocalVersionKey: ${localVersionKey}, ServerVersionKey: ${serverVersionKey}`, + ); + Sentry.captureException(err); + } throw err; } } diff --git a/mobile/services/queries/citizen.query.ts b/mobile/services/queries/citizen.query.ts index 7565918e0..d647785dd 100644 --- a/mobile/services/queries/citizen.query.ts +++ b/mobile/services/queries/citizen.query.ts @@ -7,6 +7,7 @@ import { } from "../api/citizen/get-election-round-locations"; import * as CitizenLocationsDB from "../../database/DAO/CitizenLocationsDAO"; import * as Sentry from "@sentry/react-native"; +import { isExpectedApiError } from "../api"; import { CitizenLocationVM } from "../../common/models/citizen-locations.model"; import { ElectionRoundsAllFormsAPIResponse } from "../definitions.api"; import { useCallback } from "react"; @@ -96,10 +97,12 @@ export const useGetCitizenLocations = (electionRoundId: string) => { } } catch (err) { console.warn("useGetCitizenReportingForms", err); - Sentry.captureMessage( - `Failed to get citizen reporting forms. ElectionRoundId: ${electionRoundId}`, - ); - Sentry.captureException(err); + if (!isExpectedApiError(err)) { + Sentry.captureMessage( + `Failed to get citizen reporting forms. ElectionRoundId: ${electionRoundId}`, + ); + Sentry.captureException(err); + } throw err; } } From beb123c59659d2f938b895b8e835a7f98a527bcd Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 3 Nov 2026 11:07:27 +0200 Subject: [PATCH 08/21] Mobile: add time and date settings with 24-hour and YYYY-MM-DD defaults Add a "Time and date settings" page, reached from the More screen for observers and citizens, where the user picks a 24 or 12-hour clock and a date format (YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY or MM/DD/YYYY). The choice is stored on the device and used by every date and time shown in the app and by the time pickers. Translations added for all app languages. --- .../delete-submission/[submissionId].tsx | 8 +- .../(observer)/(app)/(drawer)/(tabs)/more.tsx | 12 ++- mobile/app/(observer)/(app)/_layout.tsx | 1 + .../(observer)/(app)/date-time-settings.tsx | 3 + mobile/app/_layout.tsx | 17 ++-- .../app/citizen/main/(drawer)/(tabs)/more.tsx | 12 ++- mobile/app/citizen/main/_layout.tsx | 1 + .../app/citizen/main/date-time-settings.tsx | 3 + mobile/assets/icons/Clock.svg | 3 + mobile/assets/locales/az/translations_AZ.json | 12 +++ mobile/assets/locales/bg/translations_BG.json | 12 +++ mobile/assets/locales/de/translations_DE.json | 12 +++ mobile/assets/locales/en/translations.json | 12 +++ mobile/assets/locales/es/translations_ES.json | 12 +++ mobile/assets/locales/hy/translations_HY.json | 12 +++ mobile/assets/locales/ka/translations_KA.json | 12 +++ mobile/assets/locales/pl/translations_PL.json | 12 +++ .../locales/pt-BR/translations_PT-BR.json | 12 +++ mobile/assets/locales/ro/translations.json | 12 +++ mobile/assets/locales/ru/translations_RU.json | 12 +++ mobile/assets/locales/sr/translations_SR.json | 12 +++ mobile/common/constants.ts | 2 + mobile/common/utils/answers.ts | 33 +++++-- mobile/common/utils/time.ts | 37 +++++++ mobile/components/DateTimeSettingsScreen.tsx | 97 +++++++++++++++++++ mobile/components/FormQuestionListItem.tsx | 4 +- mobile/components/FormSubmissionListItem.tsx | 5 +- mobile/components/Icon.tsx | 2 + mobile/components/Inputs/DateInput.tsx | 31 +++--- mobile/components/NotificationListItem.tsx | 18 +--- mobile/components/PSITime.tsx | 24 ++--- mobile/components/ResourcesList.tsx | 4 +- mobile/components/ReviewCitizenFormSheet.tsx | 8 +- mobile/components/TimeSelect.tsx | 22 ++--- .../DateTimeFormatContext.provider.tsx | 73 ++++++++++++++ 35 files changed, 484 insertions(+), 80 deletions(-) create mode 100644 mobile/app/(observer)/(app)/date-time-settings.tsx create mode 100644 mobile/app/citizen/main/date-time-settings.tsx create mode 100644 mobile/assets/icons/Clock.svg create mode 100644 mobile/common/utils/time.ts create mode 100644 mobile/components/DateTimeSettingsScreen.tsx create mode 100644 mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx index 4b37547a0..544e6caf1 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx @@ -12,6 +12,7 @@ import { Screen } from "../../../../../../../components/Screen"; import { Typography } from "../../../../../../../components/Typography"; import { useNetInfoContext } from "../../../../../../../contexts/net-info-banner/NetInfoContext"; import { useUserData } from "../../../../../../../contexts/user/UserContext.provider"; +import { useDateTimeFormat } from "../../../../../../../contexts/date-time-format/DateTimeFormatContext.provider"; import { shouldDisplayQuestion } from "../../../../../../../services/form.parser"; import { ApiFormAnswer, @@ -34,6 +35,7 @@ type SearchParamsType = { const DeleteFormSubmission = () => { const { t } = useTranslation(["delete_submission", "common"]); + const dateTimeFormat = useDateTimeFormat(); const { formId, submissionId, language, submissionNumber } = useLocalSearchParams(); const { isOnline } = useNetInfoContext(); @@ -329,7 +331,11 @@ const DeleteFormSubmission = () => { {mappedAnswers && mappedAnswers[question.id] && ( - {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} + {getAnswerDisplay( + mappedAnswers[question.id] as ApiFormAnswer, + true, + dateTimeFormat, + )} )} {attachments && attachments[question.id] ? ( diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx index 19ffce2f5..c5b08e9d5 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx @@ -5,6 +5,8 @@ import { Screen } from "../../../../../components/Screen"; import { Typography } from "../../../../../components/Typography"; import { Icon } from "../../../../../components/Icon"; import { useTranslation } from "react-i18next"; +import { getDateFormatLabel } from "../../../../../common/utils/time"; +import { useDateTimeFormat } from "../../../../../contexts/date-time-format/DateTimeFormatContext.provider"; import { openExternalUrl } from "../../../../../common/utils/utils"; import { router, useNavigation } from "expo-router"; import { useAuth } from "../../../../../hooks/useAuth"; @@ -45,7 +47,8 @@ export const MoreMenuItem = ({ label, helper, icon, chevronRight, onClick }: Men ); const More = () => { - const { t, i18n } = useTranslation(["more", "languages", "common"]); + const { t, i18n } = useTranslation(["more", "languages", "common", "settings"]); + const { is24Hour, dateFormat } = useDateTimeFormat(); const { isOnline } = useNetInfoContext(); const { signOut } = useAuth(); @@ -102,6 +105,13 @@ const More = () => { onClick={setIsLanguageSelectSheetOpen.bind(null, true)} helper={t(i18n.language, { ns: "languages" })} > + router.push("/date-time-settings")} + helper={`${is24Hour ? t("time_format.24h", { ns: "settings" }) : t("time_format.12h", { ns: "settings" })} · ${getDateFormatLabel(dateFormat)}`} + > { + diff --git a/mobile/app/(observer)/(app)/date-time-settings.tsx b/mobile/app/(observer)/(app)/date-time-settings.tsx new file mode 100644 index 000000000..fabd789a8 --- /dev/null +++ b/mobile/app/(observer)/(app)/date-time-settings.tsx @@ -0,0 +1,3 @@ +import DateTimeSettingsScreen from "../../../components/DateTimeSettingsScreen"; + +export default DateTimeSettingsScreen; diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index b548a2e09..e83620aaf 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -14,6 +14,7 @@ import AppModeContextProvider from "../contexts/app-mode/AppModeContext.provider import AuthContextProvider from "../contexts/auth/AuthContext.provider"; import { EasUpdateMonitorContextProvider } from "../contexts/eas-update/EasUpdateMonitorContextProvider"; import LanguageContextProvider from "../contexts/language/LanguageContext.provider"; +import DateTimeFormatContextProvider from "../contexts/date-time-format/DateTimeFormatContext.provider"; import { NetInfoProvider } from "../contexts/net-info-banner/NetInfoContext"; import PersistQueryContextProvider from "../contexts/persist-query/PersistQueryContext.provider"; import { tamaguiConfig } from "../tamagui.config"; @@ -100,13 +101,15 @@ function RootLayout() { - - - - - - - + + + + + + + + + diff --git a/mobile/app/citizen/main/(drawer)/(tabs)/more.tsx b/mobile/app/citizen/main/(drawer)/(tabs)/more.tsx index 234162944..2afdea48c 100644 --- a/mobile/app/citizen/main/(drawer)/(tabs)/more.tsx +++ b/mobile/app/citizen/main/(drawer)/(tabs)/more.tsx @@ -5,6 +5,8 @@ import { Screen } from "../../../../../components/Screen"; import { Typography } from "../../../../../components/Typography"; import { Icon } from "../../../../../components/Icon"; import { useTranslation } from "react-i18next"; +import { getDateFormatLabel } from "../../../../../common/utils/time"; +import { useDateTimeFormat } from "../../../../../contexts/date-time-format/DateTimeFormatContext.provider"; import { openExternalUrl } from "../../../../../common/utils/utils"; import { router, useNavigation } from "expo-router"; import Header from "../../../../../components/Header"; @@ -38,7 +40,8 @@ export const MoreMenuItem = ({ label, helper, icon, chevronRight, onClick }: Men ); const More = () => { - const { t, i18n } = useTranslation(["more", "languages", "common"]); + const { t, i18n } = useTranslation(["more", "languages", "common", "settings"]); + const { is24Hour, dateFormat } = useDateTimeFormat(); const navigation = useNavigation(); @@ -72,6 +75,13 @@ const More = () => { onClick={setIsLanguageSelectSheetOpen.bind(null, true)} helper={t(i18n.language, { ns: "languages" })} > + router.push("/citizen/main/date-time-settings")} + helper={`${is24Hour ? t("time_format.24h", { ns: "settings" }) : t("time_format.12h", { ns: "settings" })} · ${getDateFormatLabel(dateFormat)}`} + > + ); diff --git a/mobile/app/citizen/main/date-time-settings.tsx b/mobile/app/citizen/main/date-time-settings.tsx new file mode 100644 index 000000000..fabd789a8 --- /dev/null +++ b/mobile/app/citizen/main/date-time-settings.tsx @@ -0,0 +1,3 @@ +import DateTimeSettingsScreen from "../../../components/DateTimeSettingsScreen"; + +export default DateTimeSettingsScreen; diff --git a/mobile/assets/icons/Clock.svg b/mobile/assets/icons/Clock.svg new file mode 100644 index 000000000..52e98ab47 --- /dev/null +++ b/mobile/assets/icons/Clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/assets/locales/az/translations_AZ.json b/mobile/assets/locales/az/translations_AZ.json index 0903eaf40..b93ce08d0 100644 --- a/mobile/assets/locales/az/translations_AZ.json +++ b/mobile/assets/locales/az/translations_AZ.json @@ -784,9 +784,21 @@ "p2": "Hər hansı yeni məlumat alıb-almadığınızı yoxlamaq üçün vaxtaşırı yoxlayın." } }, + "settings": { + "title": "Vaxt və tarix parametrləri", + "time_format": { + "title": "Vaxt formatı", + "24h": "24 saatlıq", + "12h": "12 saatlıq" + }, + "date_format": { + "title": "Tarix formatı" + } + }, "more": { "title": "Daha artıq", "change-language": "Aplikasiya dilinin dəyişdirilməsi", + "date_time_settings": "Vaxt və tarix parametrləri", "change-password": "Parolun dəyişdirilməsi", "terms": "Qaydalar və şərtlər", "privacy_policy": "Gizlilik siyasəti", diff --git a/mobile/assets/locales/bg/translations_BG.json b/mobile/assets/locales/bg/translations_BG.json index da4414b10..243ee2896 100644 --- a/mobile/assets/locales/bg/translations_BG.json +++ b/mobile/assets/locales/bg/translations_BG.json @@ -787,9 +787,21 @@ "p2": "Проверявайте периодично, за да видите дали сте получили нова информация." } }, + "settings": { + "title": "Настройки на часа и датата", + "time_format": { + "title": "Формат на часа", + "24h": "24-часов", + "12h": "12-часов" + }, + "date_format": { + "title": "Формат на датата" + } + }, "more": { "title": "Още", "change-language": "Променете езиковите настройки на приложението", + "date_time_settings": "Настройки на часа и датата", "change-password": "Променете паролата си", "terms": "Правила и условия", "privacy_policy": "Политика на поверителност", diff --git a/mobile/assets/locales/de/translations_DE.json b/mobile/assets/locales/de/translations_DE.json index 26ed6812b..44e49fc6b 100644 --- a/mobile/assets/locales/de/translations_DE.json +++ b/mobile/assets/locales/de/translations_DE.json @@ -787,9 +787,21 @@ "p2": "Schauen Sie regelmäßig nach, ob Sie neue Informationen erhalten haben." } }, + "settings": { + "title": "Zeit- und Datumseinstellungen", + "time_format": { + "title": "Zeitformat", + "24h": "24-Stunden", + "12h": "12-Stunden" + }, + "date_format": { + "title": "Datumsformat" + } + }, "more": { "title": "Mehr", "change-language": "App-Sprache ändern", + "date_time_settings": "Zeit- und Datumseinstellungen", "change-password": "Passwort ändern", "terms": "Allgemeine Geschäftsbedingungen", "privacy_policy": "Datenschutzerklärung", diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 545b19e73..d5e063f43 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -787,9 +787,21 @@ "p2": "Check back periodically to see if you have received any new information." } }, + "settings": { + "title": "Time and date settings", + "time_format": { + "title": "Time format", + "24h": "24-hour", + "12h": "12-hour" + }, + "date_format": { + "title": "Date format" + } + }, "more": { "title": "More", "change-language": "Change app language", + "date_time_settings": "Time and date settings", "change-password": "Change password", "terms": "Terms and Conditions", "privacy_policy": "Privacy Policy", diff --git a/mobile/assets/locales/es/translations_ES.json b/mobile/assets/locales/es/translations_ES.json index 1715c11d3..10ec2e1f2 100644 --- a/mobile/assets/locales/es/translations_ES.json +++ b/mobile/assets/locales/es/translations_ES.json @@ -786,9 +786,21 @@ "p2": "Revisa periódicamente para ver si has recibido nueva información." } }, + "settings": { + "title": "Ajustes de hora y fecha", + "time_format": { + "title": "Formato de hora", + "24h": "24 horas", + "12h": "12 horas" + }, + "date_format": { + "title": "Formato de fecha" + } + }, "more": { "title": "Más", "change-language": "Cambiar idioma de la app", + "date_time_settings": "Ajustes de hora y fecha", "change-password": "Cambiar contraseña", "terms": "Términos y condiciones", "privacy_policy": "Política de privacidad", diff --git a/mobile/assets/locales/hy/translations_HY.json b/mobile/assets/locales/hy/translations_HY.json index 6667d4b4d..969d0e951 100644 --- a/mobile/assets/locales/hy/translations_HY.json +++ b/mobile/assets/locales/hy/translations_HY.json @@ -787,9 +787,21 @@ "p2": "Պարբերաբար ստուգեք՝ ստացել եք արդյոք նոր տեղեկություններ: " } }, + "settings": { + "title": "Ժամի և ամսաթվի կարգավորումներ", + "time_format": { + "title": "Ժամի ձևաչափ", + "24h": "24-ժամյա", + "12h": "12-ժամյա" + }, + "date_format": { + "title": "Ամսաթվի ձևաչափ" + } + }, "more": { "title": "Ավելին", "change-language": "Փոխել հավելվածի լեզուն", + "date_time_settings": "Ժամի և ամսաթվի կարգավորումներ", "change-password": "Փոխել գաղտնաբառը", "terms": "Կանոններ և պայմաններ", "privacy_policy": "Գաղտնիության քաղաքականություն", diff --git a/mobile/assets/locales/ka/translations_KA.json b/mobile/assets/locales/ka/translations_KA.json index 875aa59e5..beab33622 100644 --- a/mobile/assets/locales/ka/translations_KA.json +++ b/mobile/assets/locales/ka/translations_KA.json @@ -783,9 +783,21 @@ "p2": "პერიოდულად შეამოწმეთ, მიგიღიათ თუ არა ახალი ინფორმაცია" } }, + "settings": { + "title": "დროისა და თარიღის პარამეტრები", + "time_format": { + "title": "დროის ფორმატი", + "24h": "24-საათიანი", + "12h": "12-საათიანი" + }, + "date_format": { + "title": "თარიღის ფორმატი" + } + }, "more": { "title": "მეტი", "change-language": "აპლიკაციის ენის შეცვლა", + "date_time_settings": "დროისა და თარიღის პარამეტრები", "change-password": "პაროლის შეცვლა", "terms": "წესები და პირობები", "privacy_policy": "კონფიდენციალურობის პოლიტიკა", diff --git a/mobile/assets/locales/pl/translations_PL.json b/mobile/assets/locales/pl/translations_PL.json index d2101e946..17e1ef0a8 100644 --- a/mobile/assets/locales/pl/translations_PL.json +++ b/mobile/assets/locales/pl/translations_PL.json @@ -791,9 +791,21 @@ "p2": "Sprawdzaj okresowo, czy otrzymałeś jakieś nowe informacje." } }, + "settings": { + "title": "Ustawienia godziny i daty", + "time_format": { + "title": "Format godziny", + "24h": "24-godzinny", + "12h": "12-godzinny" + }, + "date_format": { + "title": "Format daty" + } + }, "more": { "title": "Więcej", "change-language": "Zmień język aplikacji", + "date_time_settings": "Ustawienia godziny i daty", "change-password": "Zmień hasło", "terms": "Regulamin", "privacy_policy": "Polityka prywatności", diff --git a/mobile/assets/locales/pt-BR/translations_PT-BR.json b/mobile/assets/locales/pt-BR/translations_PT-BR.json index 58889d451..f5fdc16b1 100644 --- a/mobile/assets/locales/pt-BR/translations_PT-BR.json +++ b/mobile/assets/locales/pt-BR/translations_PT-BR.json @@ -787,9 +787,21 @@ "p2": "Verifique periodicamente para ver se você recebeu alguma informação nova." } }, + "settings": { + "title": "Configurações de hora e data", + "time_format": { + "title": "Formato de hora", + "24h": "24 horas", + "12h": "12 horas" + }, + "date_format": { + "title": "Formato de data" + } + }, "more": { "title": "Mais", "change-language": "Alterar o idioma do aplicativo", + "date_time_settings": "Configurações de hora e data", "change-password": "Alterar senha", "terms": "Termos e Condições", "privacy_policy": "Política de Privacidade", diff --git a/mobile/assets/locales/ro/translations.json b/mobile/assets/locales/ro/translations.json index ef69175de..ab2bcb102 100644 --- a/mobile/assets/locales/ro/translations.json +++ b/mobile/assets/locales/ro/translations.json @@ -787,9 +787,21 @@ "p2": "Verifică periodic pentru a vedea dacă ai primit informații noi." } }, + "settings": { + "title": "Setări de oră și dată", + "time_format": { + "title": "Format oră", + "24h": "24 de ore", + "12h": "12 ore" + }, + "date_format": { + "title": "Format dată" + } + }, "more": { "title": "Altele", "change-language": "Schimbă limba aplicației", + "date_time_settings": "Setări de oră și dată", "change-password": "Schimbă parola", "terms": "Termeni și Condiții", "privacy_policy": "Politica de Confidențialitate", diff --git a/mobile/assets/locales/ru/translations_RU.json b/mobile/assets/locales/ru/translations_RU.json index 0f3615d99..4adb1a7ee 100644 --- a/mobile/assets/locales/ru/translations_RU.json +++ b/mobile/assets/locales/ru/translations_RU.json @@ -784,9 +784,21 @@ "p2": "Периодически заглядывайте сюда, чтобы проверить, не получили ли вы новую информацию." } }, + "settings": { + "title": "Настройки времени и даты", + "time_format": { + "title": "Формат времени", + "24h": "24-часовой", + "12h": "12-часовой" + }, + "date_format": { + "title": "Формат даты" + } + }, "more": { "title": "Больше", "change-language": "Изменить язык аппликации", + "date_time_settings": "Настройки времени и даты", "change-password": "Изменить пароль", "terms": "Правила и условия", "privacy_policy": "Политика конфиденциальности", diff --git a/mobile/assets/locales/sr/translations_SR.json b/mobile/assets/locales/sr/translations_SR.json index b894eb46b..ea876b80d 100644 --- a/mobile/assets/locales/sr/translations_SR.json +++ b/mobile/assets/locales/sr/translations_SR.json @@ -784,9 +784,21 @@ "p2": "Proveravajte periodično da li ste primili nove informacije." } }, + "settings": { + "title": "Podešavanja vremena i datuma", + "time_format": { + "title": "Format vremena", + "24h": "24-časovni", + "12h": "12-časovni" + }, + "date_format": { + "title": "Format datuma" + } + }, "more": { "title": "Još opcija", "change-language": "Promenite jezik aplikacije", + "date_time_settings": "Podešavanja vremena i datuma", "change-password": "Promenite loziku", "terms": "Uslovi korišćenja", "privacy_policy": "Politika privatnosti", diff --git a/mobile/common/constants.ts b/mobile/common/constants.ts index ef6d37c17..502984d4e 100644 --- a/mobile/common/constants.ts +++ b/mobile/common/constants.ts @@ -11,6 +11,8 @@ export const SECURE_STORAGE_KEYS = { ONBOARDING_COMPLETE: "onboardingComplete", ONBOARDING_NEW_COMPLETE: "onboardingNewComplete", I18N_LANGUAGE: "i18n-language", + CLOCK_FORMAT: "clock-format", + DATE_FORMAT: "date-format", }; // export const I18N_LANGUAGE = "i18n-language"; diff --git a/mobile/common/utils/answers.ts b/mobile/common/utils/answers.ts index 7dc40ba68..707212d31 100644 --- a/mobile/common/utils/answers.ts +++ b/mobile/common/utils/answers.ts @@ -1,4 +1,11 @@ -import { format } from "date-fns"; +import { + DateFormat, + DEFAULT_DATE_FORMAT, + formatDate, + formatDayIso, + formatTime, + formatTimeOfDay, +} from "./time"; import { ApiFormAnswer } from "../../services/interfaces/answer.type"; /** @@ -6,8 +13,8 @@ import { ApiFormAnswer } from "../../services/interfaces/answer.type"; * * @param {ApiFormAnswer} answer - The ApiFormAnswer object to get the display string for. * depending on the answer type, the value has a different key - * type date -> date (the day only) - * type time -> time ("HH:mm") + * type date -> date (the day only), shown in the chosen date format + * type time -> time ("HH:mm:ss"), shown in the chosen clock format * type dateTime -> dateTime * type text -> text * type number -> value @@ -17,17 +24,27 @@ import { ApiFormAnswer } from "../../services/interfaces/answer.type"; * @return {string} The display string for the given ApiFormAnswer object. */ -export const getAnswerDisplay = (answer: ApiFormAnswer, displaySingleSelectOtherText?: boolean) => { +export const getAnswerDisplay = ( + answer: ApiFormAnswer, + displaySingleSelectOtherText?: boolean, + formats: { is24Hour: boolean; dateFormat: DateFormat } = { + is24Hour: true, + dateFormat: DEFAULT_DATE_FORMAT, + }, +) => { if (!answer) return ""; switch (answer.$answerType) { case "dateAnswer": // a calendar day: shown as received, without a time or a time zone shift - return answer.date?.slice(0, 10) ?? ""; + return answer.date ? formatDayIso(answer.date, formats.dateFormat) : ""; case "timeAnswer": - return answer.time?.slice(0, 5) ?? ""; - case "dateTimeAnswer": - return answer.dateTime ? format(new Date(answer.dateTime), "yyyy-MM-dd HH:mm") : ""; + return answer.time ? formatTimeOfDay(answer.time, formats.is24Hour) : ""; + case "dateTimeAnswer": { + if (!answer.dateTime) return ""; + const dateTime = new Date(answer.dateTime); + return `${formatDate(dateTime, formats.dateFormat)} ${formatTime(dateTime, formats.is24Hour)}`; + } case "textAnswer": return answer.text; case "singleSelectAnswer": diff --git a/mobile/common/utils/time.ts b/mobile/common/utils/time.ts new file mode 100644 index 000000000..e54565ad2 --- /dev/null +++ b/mobile/common/utils/time.ts @@ -0,0 +1,37 @@ +import { format } from "date-fns"; + +// the date formats the user can pick in settings, as date-fns patterns +export const DATE_FORMATS = ["yyyy-MM-dd", "dd.MM.yyyy", "dd/MM/yyyy", "MM/dd/yyyy"] as const; +export type DateFormat = (typeof DATE_FORMATS)[number]; +export const DEFAULT_DATE_FORMAT: DateFormat = "yyyy-MM-dd"; + +/** A date-fns pattern as users know it, e.g. "yyyy-MM-dd" -> "YYYY-MM-DD". */ +export const getDateFormatLabel = (dateFormat: DateFormat) => dateFormat.toUpperCase(); + +/** A time of the day, e.g. "13:05" or "1:05 PM". */ +export const formatTime = (value: Date, is24Hour: boolean) => + format(value, is24Hour ? "HH:mm" : "h:mm a"); + +/** A calendar day in the chosen date format, e.g. "2026-11-03" or "03.11.2026". */ +export const formatDate = (value: Date, dateFormat: DateFormat) => format(value, dateFormat); + +/** A calendar day stored as "YYYY-MM-DD", shown in the chosen date format without a time zone shift. */ +export const formatDayIso = (day: string, dateFormat: DateFormat) => { + const [year, month, date] = day.slice(0, 10).split("-").map(Number); + if ([year, month, date].some(Number.isNaN)) return day.slice(0, 10); + return formatDate(new Date(year, month - 1, date), dateFormat); +}; + +/** A wall-clock time stored as "HH:mm" or "HH:mm:ss", shown in the chosen clock format. */ +export const formatTimeOfDay = (time: string, is24Hour: boolean) => { + const [hours, minutes] = time.split(":").map(Number); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return time.slice(0, 5); + return formatTime(new Date(2000, 0, 1, hours, minutes), is24Hour); +}; + +/** + * The iOS picker has no 24-hour prop: its clock follows the region of its locale. + * Keep the language (for month names) and pick a region with the wanted clock. + */ +export const getPickerLocale = (language: string, is24Hour: boolean) => + `${language.split("-")[0]}_${is24Hour ? "GB" : "US"}`; diff --git a/mobile/components/DateTimeSettingsScreen.tsx b/mobile/components/DateTimeSettingsScreen.tsx new file mode 100644 index 000000000..9a8a19240 --- /dev/null +++ b/mobile/components/DateTimeSettingsScreen.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { router } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { XStack, YStack } from "tamagui"; +import { DATE_FORMATS, getDateFormatLabel, formatDate, formatTime } from "../common/utils/time"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; +import Card from "./Card"; +import Header from "./Header"; +import { Icon } from "./Icon"; +import { Screen } from "./Screen"; +import { Typography } from "./Typography"; + +interface SettingsOptionProps { + label: string; + example: string; + selected: boolean; + onPress: () => void; +} + +const SettingsOption = ({ label, example, selected, onPress }: SettingsOptionProps) => ( + + + + {label} + {example} + + {selected && } + + +); + +const DateTimeSettingsScreen = () => { + const { t } = useTranslation("settings"); + const { is24Hour, setIs24Hour, dateFormat, setDateFormat } = useDateTimeFormat(); + + // a fixed afternoon shows the difference between the 12 and 24-hour clocks + const now = new Date(); + const afternoon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 13, 30); + + return ( + +
} + onLeftPress={() => router.back()} + /> + + + + {t("time_format.title")} + + {[true, false].map((option) => ( + setIs24Hour(option)} + /> + ))} + + + + + {t("date_format.title")} + + {DATE_FORMATS.map((option) => ( + setDateFormat(option)} + /> + ))} + + + + ); +}; + +export default DateTimeSettingsScreen; diff --git a/mobile/components/FormQuestionListItem.tsx b/mobile/components/FormQuestionListItem.tsx index bc4826851..b6d5c663c 100644 --- a/mobile/components/FormQuestionListItem.tsx +++ b/mobile/components/FormQuestionListItem.tsx @@ -7,6 +7,7 @@ import { ApiFormAnswer } from "../services/interfaces/answer.type"; import QuestionCardFooter from "./QuestionCardFooter"; import { AttachmentMimeType } from "../services/api/get-attachments.api"; import { getAnswerDisplay } from "../common/utils/answers"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; export enum QuestionStatus { ANSWERED = "answered", @@ -42,6 +43,7 @@ const FormQuestionListItem = ({ onClick, }: FormQuestionListItemProps) => { const { t } = useTranslation("form_overview"); + const dateTimeFormat = useDateTimeFormat(); const QuestionStatusToTextWrapper = { [QuestionStatus.ANSWERED]: t("questions.answered"), @@ -62,7 +64,7 @@ const FormQuestionListItem = ({ {answer && ( - {getAnswerDisplay(answer)} + {getAnswerDisplay(answer, false, dateTimeFormat)} )} { const { t } = useTranslation(["form_submission_card", "common"]); + const { formatDate, formatTime } = useDateTimeFormat(); return ( @@ -57,7 +58,7 @@ const FormSubmissionListItem = ({ {t("last_updated_at")} - {format(lastUpdatedAt, "yyyy-MM-dd HH:mm")} + {`${formatDate(new Date(lastUpdatedAt))} ${formatTime(new Date(lastUpdatedAt))}`} {t("form_status")} diff --git a/mobile/components/Icon.tsx b/mobile/components/Icon.tsx index 1b18e4336..ff884a1c1 100644 --- a/mobile/components/Icon.tsx +++ b/mobile/components/Icon.tsx @@ -18,6 +18,7 @@ import MenuAlt2 from "../assets/icons/menu-alt-2.svg"; import DotsVertical from "../assets/icons/dots-vertical.svg"; import Check from "../assets/icons/check.svg"; import Calendar from "../assets/icons/Calendar.svg"; +import Clock from "../assets/icons/Clock.svg"; import PeopleAddingVote from "../assets/icons/people-adding-vote.svg"; import LoadingScreenDevice from "../assets/icons/loading-screen-device.svg"; import MissingPollingStation from "../assets/icons/missing-polling-station.svg"; @@ -126,6 +127,7 @@ export const defaultIcon = (props: IconProps): React.ReactElement => { dotsVertical: , check: , calendar: , + clock: , peopleAddingVote: , loadingScreenDevice: , missingPollingStation: , diff --git a/mobile/components/Inputs/DateInput.tsx b/mobile/components/Inputs/DateInput.tsx index c6667a42c..86907cb1b 100644 --- a/mobile/components/Inputs/DateInput.tsx +++ b/mobile/components/Inputs/DateInput.tsx @@ -11,6 +11,8 @@ import Button from "../Button"; import { useLocalSearchParams } from "expo-router"; import { useTranslation } from "react-i18next"; import useAnimatedKeyboardPadding from "../../hooks/useAnimatedKeyboardPadding"; +import { useDateTimeFormat } from "../../contexts/date-time-format/DateTimeFormatContext.provider"; +import { getPickerLocale } from "../../common/utils/time"; export type DateInputMode = "date" | "time" | "datetime"; @@ -25,15 +27,15 @@ export interface DateInputProps extends XStackProps { error?: string; } -const formatDay = (value: Date) => - value.toLocaleDateString(["en-GB"], { month: "2-digit", day: "2-digit", year: "numeric" }); -const formatTime = (value: Date) => - value.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - -const formatValue = (value: Date, mode: DateInputMode) => { - if (mode === "date") return formatDay(value); +const formatValue = ( + value: Date, + mode: DateInputMode, + formatDate: (value: Date) => string, + formatTime: (value: Date) => string, +) => { + if (mode === "date") return formatDate(value); if (mode === "time") return formatTime(value); - return `${formatDay(value)} - ${formatTime(value)}`; + return `${formatDate(value)} - ${formatTime(value)}`; }; export const DateInput: React.FC = ({ @@ -52,7 +54,8 @@ export const DateInput: React.FC = ({ // on ios we use a temporary date, as the onChange function gets triggered every time the user picks a new date // therefore, we will update the FINAL date state (that comes from the outside), only onDonePress const [tempDate, setTempDate] = useState(value || new Date()); - const { t } = useTranslation("common"); + const { t, i18n } = useTranslation("common"); + const { is24Hour, formatDate, formatTime } = useDateTimeFormat(); const handleSheetOpen = () => { Keyboard.dismiss(); @@ -80,6 +83,7 @@ export const DateInput: React.FC = ({ onChange(selectedDate); } }, + is24Hour, }); } else if (event.type === "dismissed") { // press Cancel - close modal @@ -121,7 +125,7 @@ export const DateInput: React.FC = ({ numberOfLines={1} width="90%" > - {value ? formatValue(value, mode) : placeholder} + {value ? formatValue(value, mode, formatDate, formatTime) : placeholder} {/* open bottom sheet on ios with date picker */} @@ -140,7 +144,10 @@ export const DateInput: React.FC = ({ onChange={onDateChange} minimumDate={minimumDate || undefined} maximumDate={maximumDate || undefined} - locale={localParams.language as string} + locale={getPickerLocale( + (localParams.language as string) || i18n.language, + is24Hour, + )} /> @@ -152,7 +159,7 @@ export const DateInput: React.FC = ({ mode: mode === "time" ? "time" : "date", value: value || new Date(), onChange: onDateChange, - is24Hour: true, + is24Hour, minimumDate: minimumDate || undefined, maximumDate: maximumDate || undefined, }) diff --git a/mobile/components/NotificationListItem.tsx b/mobile/components/NotificationListItem.tsx index 2de36519f..ffa4d808a 100644 --- a/mobile/components/NotificationListItem.tsx +++ b/mobile/components/NotificationListItem.tsx @@ -4,10 +4,12 @@ import { useWindowDimensions, YStack } from "tamagui"; import { Typography } from "./Typography"; import { useTranslation } from "react-i18next"; import Card from "./Card"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; import RenderHtml from "react-native-render-html"; const NotificationListItem = ({ notification }: { notification: Notification }) => { - const { t, i18n } = useTranslation("inbox"); + const { t } = useTranslation("inbox"); + const { formatDate, formatTime } = useDateTimeFormat(); const sentAt = new Date(notification.sentAt); const isToday = sentAt.toDateString() === new Date().toDateString(); @@ -18,18 +20,8 @@ const NotificationListItem = ({ notification }: { notification: Notification }) {isToday - ? `${t("today")}, ` + - sentAt.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : sentAt.toLocaleString(i18n.language, { - month: "short", - day: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - })} + ? `${t("today")}, ${formatTime(sentAt)}` + : `${formatDate(sentAt)}, ${formatTime(sentAt)}`} { const { t } = useTranslation("observation"); + const { formatDate, formatTime } = useDateTimeFormat(); const handleNavigateToObservationTime = () => { router.push("/observation-time"); @@ -49,11 +51,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.arrivalTime ? "$gray7" : "$gray3"} > {psiData.arrivalTime - ? new Date(psiData.arrivalTime).toLocaleDateString(["en-GB"], { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) + ? formatDate(new Date(psiData.arrivalTime)) : t("polling_stations_information.observation_time.undefined_date")} @@ -63,10 +61,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.arrivalTime ? "$gray7" : "$gray3"} > {psiData.arrivalTime - ? new Date(psiData.arrivalTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) + ? formatTime(new Date(psiData.arrivalTime)) : t("polling_stations_information.observation_time.undefined_time")} @@ -85,11 +80,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.departureTime ? "$gray7" : "$gray3"} > {psiData.departureTime - ? new Date(psiData.departureTime).toLocaleDateString(["en-GB"], { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) + ? formatDate(new Date(psiData.departureTime)) : t("polling_stations_information.observation_time.undefined_date")} @@ -99,10 +90,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.departureTime ? "$gray7" : "$gray3"} > {psiData.departureTime - ? new Date(psiData.departureTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) + ? formatTime(new Date(psiData.departureTime)) : t("polling_stations_information.observation_time.undefined_time")} diff --git a/mobile/components/ResourcesList.tsx b/mobile/components/ResourcesList.tsx index d531e5f27..a82db5535 100644 --- a/mobile/components/ResourcesList.tsx +++ b/mobile/components/ResourcesList.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import Card, { CardProps } from "./Card"; import CardFooter from "./CardFooter"; import { useNetInfoContext } from "../contexts/net-info-banner/NetInfoContext"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; interface GuideCardProps extends CardProps { guide: Guide; @@ -18,6 +19,7 @@ interface GuideCardProps extends CardProps { const GuideCard = ({ guide, onResourcePress, ...rest }: GuideCardProps) => { const { t } = useTranslation(["guides", "resources"]); + const { formatDate, formatTime } = useDateTimeFormat(); const createdOn = new Date(guide.createdOn); const handleGuidePress = () => { @@ -46,7 +48,7 @@ const GuideCard = ({ guide, onResourcePress, ...rest }: GuideCardProps) => { )} {createdOn && ( )} diff --git a/mobile/components/ReviewCitizenFormSheet.tsx b/mobile/components/ReviewCitizenFormSheet.tsx index 99f105e8e..82f4688a5 100644 --- a/mobile/components/ReviewCitizenFormSheet.tsx +++ b/mobile/components/ReviewCitizenFormSheet.tsx @@ -35,6 +35,7 @@ import { useUploadS3ChunkMutation, } from "../services/mutations/attachments/add-attachment.mutation"; import { useNetInfoContext } from "../contexts/net-info-banner/NetInfoContext"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; import { useQueryClient } from "@tanstack/react-query"; import { AttachmentData } from "../services/api/add-attachment.api"; @@ -72,6 +73,7 @@ export default function ReviewCitizenFormSheet({ }) { const cancelRef = useRef(false); const { t } = useTranslation("citizen_form"); + const dateTimeFormat = useDateTimeFormat(); const insets = useSafeAreaInsets(); const router = useRouter(); const { isOnline } = useNetInfoContext(); @@ -403,7 +405,11 @@ export default function ReviewCitizenFormSheet({ {question.text[language]} {mappedAnswers && mappedAnswers[question.id] && ( - {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} + {getAnswerDisplay( + mappedAnswers[question.id] as ApiFormAnswer, + true, + dateTimeFormat, + )} )} {attachments && attachments[question.id] ? ( diff --git a/mobile/components/TimeSelect.tsx b/mobile/components/TimeSelect.tsx index af86d0b4b..b8ff6ce06 100644 --- a/mobile/components/TimeSelect.tsx +++ b/mobile/components/TimeSelect.tsx @@ -10,6 +10,8 @@ import RNDateTimePicker, { import CardFooter from "../components/CardFooter"; import Toast from "react-native-toast-message"; import { useTranslation } from "react-i18next"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; +import { getPickerLocale } from "../common/utils/time"; interface TimeSelectProps { type: "arrival" | "departure"; @@ -22,6 +24,7 @@ interface TimeSelectProps { const TimeSelect: React.FC = memo( ({ type, time, setTime, arrivalTime, departureTime }) => { const { t, i18n } = useTranslation("observation"); + const { is24Hour, formatDate, formatTime } = useDateTimeFormat(); const [open, setOpen] = useState(false); // on ios we use a temporary time, as the onChange function gets triggered every time the user picks a new time // therefore, we will update the FINAL time state (that comes from the outside), only onDonePress @@ -101,7 +104,7 @@ const TimeSelect: React.FC = memo( setTime(finalTime); } }, - is24Hour: true, + is24Hour, }); } else if (event.type === "dismissed") { // press Cancel - close modal @@ -149,19 +152,10 @@ const TimeSelect: React.FC = memo( {time ? ( - {time && - time.toLocaleDateString(["en-GB"], { - month: "2-digit", - day: "2-digit", - year: "numeric", - })} + {time && formatDate(time)} - {time && - time.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + {time && formatTime(time)} ) : ( @@ -210,7 +204,7 @@ const TimeSelect: React.FC = memo( minimumDate={type === "departure" && arrivalTime ? arrivalTime : undefined} // if setting arrival time and departure time has already been set -> don't allow a later time maximumDate={type === "arrival" && departureTime ? departureTime : undefined} - locale={i18n.language} + locale={getPickerLocale(i18n.language, is24Hour)} /> @@ -222,7 +216,7 @@ const TimeSelect: React.FC = memo( mode: "date", value: time || new Date(), onChange, - is24Hour: true, + is24Hour, // if setting departure date and arrival date has already been set -> don't allow an earlier date minimumDate: type === "departure" && arrivalTime ? arrivalTime : undefined, maximumDate: type === "arrival" && departureTime ? departureTime : undefined, diff --git a/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx b/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx new file mode 100644 index 000000000..cdf5e5576 --- /dev/null +++ b/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx @@ -0,0 +1,73 @@ +import { createContext, useContext, useState } from "react"; +import { SECURE_STORAGE_KEYS } from "../../common/constants"; +import { + DATE_FORMATS, + DateFormat, + DEFAULT_DATE_FORMAT, + formatDate, + formatTime, +} from "../../common/utils/time"; +import { getSecureStoreItem, setSecureStoreItem } from "../../helpers/SecureStoreWrapper"; + +type DateTimeFormatContextType = { + // times are shown on a 24-hour clock unless the user picked the 12-hour one in settings + is24Hour: boolean; + setIs24Hour: (is24Hour: boolean) => void; + // dates are shown as YYYY-MM-DD unless the user picked another format in settings + dateFormat: DateFormat; + setDateFormat: (dateFormat: DateFormat) => void; + formatTime: (value: Date) => string; + formatDate: (value: Date) => string; +}; + +export const DateTimeFormatContext = createContext({ + is24Hour: true, + setIs24Hour: (_is24Hour: boolean) => {}, + dateFormat: DEFAULT_DATE_FORMAT, + setDateFormat: (_dateFormat: DateFormat) => {}, + formatTime: (value: Date) => formatTime(value, true), + formatDate: (value: Date) => formatDate(value, DEFAULT_DATE_FORMAT), +}); + +const getStoredDateFormat = (): DateFormat => { + const stored = getSecureStoreItem(SECURE_STORAGE_KEYS.DATE_FORMAT); + return DATE_FORMATS.find((dateFormat) => dateFormat === stored) ?? DEFAULT_DATE_FORMAT; +}; + +const DateTimeFormatContextProvider = ({ children }: React.PropsWithChildren) => { + const [is24Hour, setIs24HourState] = useState( + () => getSecureStoreItem(SECURE_STORAGE_KEYS.CLOCK_FORMAT) !== "12h", + ); + const [dateFormat, setDateFormatState] = useState(getStoredDateFormat); + + const setIs24Hour = (value: boolean) => { + setIs24HourState(value); + setSecureStoreItem(SECURE_STORAGE_KEYS.CLOCK_FORMAT, value ? "24h" : "12h"); + }; + + const setDateFormat = (value: DateFormat) => { + setDateFormatState(value); + setSecureStoreItem(SECURE_STORAGE_KEYS.DATE_FORMAT, value); + }; + + return ( + formatTime(value, is24Hour), + formatDate: (value: Date) => formatDate(value, dateFormat), + }} + > + {children} + + ); +}; + +export const useDateTimeFormat = () => { + return useContext(DateTimeFormatContext); +}; + +export default DateTimeFormatContextProvider; From 57a9c746725b51250034b8446bca33bca2a57bb2 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 3 Nov 2026 11:16:30 +0200 Subject: [PATCH 09/21] Mobile: rename the time and date settings i18n namespace to dateTimeFormatSettings --- mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx | 4 ++-- mobile/app/citizen/main/(drawer)/(tabs)/more.tsx | 4 ++-- mobile/assets/locales/az/translations_AZ.json | 2 +- mobile/assets/locales/bg/translations_BG.json | 2 +- mobile/assets/locales/de/translations_DE.json | 2 +- mobile/assets/locales/en/translations.json | 2 +- mobile/assets/locales/es/translations_ES.json | 2 +- mobile/assets/locales/hy/translations_HY.json | 2 +- mobile/assets/locales/ka/translations_KA.json | 2 +- mobile/assets/locales/pl/translations_PL.json | 2 +- mobile/assets/locales/pt-BR/translations_PT-BR.json | 2 +- mobile/assets/locales/ro/translations.json | 2 +- mobile/assets/locales/ru/translations_RU.json | 2 +- mobile/assets/locales/sr/translations_SR.json | 2 +- mobile/components/DateTimeSettingsScreen.tsx | 2 +- 15 files changed, 17 insertions(+), 17 deletions(-) diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx index c5b08e9d5..98a8f09c0 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx @@ -47,7 +47,7 @@ export const MoreMenuItem = ({ label, helper, icon, chevronRight, onClick }: Men ); const More = () => { - const { t, i18n } = useTranslation(["more", "languages", "common", "settings"]); + const { t, i18n } = useTranslation(["more", "languages", "common", "dateTimeFormatSettings"]); const { is24Hour, dateFormat } = useDateTimeFormat(); const { isOnline } = useNetInfoContext(); @@ -110,7 +110,7 @@ const More = () => { icon="clock" chevronRight={true} onClick={() => router.push("/date-time-settings")} - helper={`${is24Hour ? t("time_format.24h", { ns: "settings" }) : t("time_format.12h", { ns: "settings" })} · ${getDateFormatLabel(dateFormat)}`} + helper={`${is24Hour ? t("time_format.24h", { ns: "dateTimeFormatSettings" }) : t("time_format.12h", { ns: "dateTimeFormatSettings" })} · ${getDateFormatLabel(dateFormat)}`} > { - const { t, i18n } = useTranslation(["more", "languages", "common", "settings"]); + const { t, i18n } = useTranslation(["more", "languages", "common", "dateTimeFormatSettings"]); const { is24Hour, dateFormat } = useDateTimeFormat(); const navigation = useNavigation(); @@ -80,7 +80,7 @@ const More = () => { icon="clock" chevronRight={true} onClick={() => router.push("/citizen/main/date-time-settings")} - helper={`${is24Hour ? t("time_format.24h", { ns: "settings" }) : t("time_format.12h", { ns: "settings" })} · ${getDateFormatLabel(dateFormat)}`} + helper={`${is24Hour ? t("time_format.24h", { ns: "dateTimeFormatSettings" }) : t("time_format.12h", { ns: "dateTimeFormatSettings" })} · ${getDateFormatLabel(dateFormat)}`} > { - const { t } = useTranslation("settings"); + const { t } = useTranslation("dateTimeFormatSettings"); const { is24Hour, setIs24Hour, dateFormat, setDateFormat } = useDateTimeFormat(); // a fixed afternoon shows the difference between the 12 and 24-hour clocks From 8b93945ca811c3220bfccb98542b59af18580df8 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Wed, 4 Nov 2026 10:38:58 +0200 Subject: [PATCH 10/21] Mobile: stop the image picker decoding full-resolution photos A picker quality below 1 makes expo-image-picker on Android decode the whole photo into a bitmap before re-encoding it, which runs out of memory on 50-200MP cameras. Keep quality at 1 and let react-native-compressor do the downsampled resize. --- mobile/hooks/useCamera.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index 42b4af039..27ce9f3e6 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -78,7 +78,10 @@ export const useCamera = () => { const result = await luncher({ ...(specifiedMediaType || { mediaTypes: ImagePicker.MediaTypeOptions.All }), - quality: 0.1, + // Keep at 1: any value below 1 makes the picker decode the full-resolution photo into + // a bitmap on Android (hundreds of MB on 50–200MP cameras) and crashes with OOM. + // Image.compress below does a downsampled decode instead and handles the size. + quality: 1, allowsMultipleSelection: false, videoQuality: ImagePicker.UIImagePickerControllerQualityType.Low, // TODO: careful here, Medium might be enough cameraType: ImagePicker.CameraType.back, From 7a613561dbbe304043ee48be0c83d47674e483bd Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Wed, 4 Nov 2026 10:38:58 +0200 Subject: [PATCH 11/21] Mobile: upload attachment parts from disk instead of base64 in JS Each 10MB part was read as a base64 string, turned into a Buffer and re-encoded by the network layer, holding ~40MB of JS memory per part. Upload parts natively with FileSystem.uploadAsync: single-part files go straight from disk, larger files copy each byte range to a temp file first. --- .../(app)/form-questionnaire/[questionId].tsx | 13 ++-- .../(app)/form-submissions/[submissionId].tsx | 13 ++-- mobile/app/(observer)/(app)/report-issue.tsx | 13 ++-- mobile/components/ReviewCitizenFormSheet.tsx | 14 ++-- mobile/services/api/add-attachment.api.ts | 73 +++++++++++++++---- .../attachments/add-attachment.mutation.ts | 4 +- 6 files changed, 82 insertions(+), 48 deletions(-) diff --git a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx index 7b893a294..e52f7f2c2 100644 --- a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx @@ -1,9 +1,7 @@ import * as Sentry from "@sentry/react-native"; import { useQueryClient } from "@tanstack/react-query"; -import { Buffer } from "buffer"; import * as Crypto from "expo-crypto"; import * as DocumentPicker from "expo-document-picker"; -import * as FileSystem from "expo-file-system/legacy"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -516,13 +514,12 @@ const FormQuestionnaire = () => { throw new Error("Upload cancelled"); } - const chunk = await FileSystem.readAsStringAsync(filePath, { - length: MULTIPART_FILE_UPLOAD_SIZE, - position: index * MULTIPART_FILE_UPLOAD_SIZE, - encoding: FileSystem.EncodingType.Base64, + const data = await uploadS3Chunk({ + url, + filePath, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, }); - const buffer = Buffer.from(chunk, "base64"); - const data = await uploadS3Chunk({ url, chunk: buffer }); setUploadProgress( `${t("attachments.upload.progress")} ${Math.ceil(((index + 1) / totalParts) * 100)}%`, ); diff --git a/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx b/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx index a942ab263..a181e5691 100644 --- a/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx +++ b/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx @@ -1,9 +1,7 @@ import * as Sentry from "@sentry/react-native"; import { useQueryClient } from "@tanstack/react-query"; -import { Buffer } from "buffer"; import * as Crypto from "expo-crypto"; import * as DocumentPicker from "expo-document-picker"; -import * as FileSystem from "expo-file-system/legacy"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -522,13 +520,12 @@ const FormSubmissions = () => { throw new Error("Upload cancelled"); } - const chunk = await FileSystem.readAsStringAsync(filePath, { - length: MULTIPART_FILE_UPLOAD_SIZE, - position: index * MULTIPART_FILE_UPLOAD_SIZE, - encoding: FileSystem.EncodingType.Base64, + const data = await uploadS3Chunk({ + url, + filePath, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, }); - const buffer = Buffer.from(chunk, "base64"); - const data = await uploadS3Chunk({ url, chunk: buffer }); setUploadProgress( `${t("attachments.upload.progress")} ${Math.ceil(((index + 1) / totalParts) * 100)}%`, ); diff --git a/mobile/app/(observer)/(app)/report-issue.tsx b/mobile/app/(observer)/(app)/report-issue.tsx index 6bc2abf18..b6181a7bd 100644 --- a/mobile/app/(observer)/(app)/report-issue.tsx +++ b/mobile/app/(observer)/(app)/report-issue.tsx @@ -42,8 +42,6 @@ import { MULTIPART_FILE_UPLOAD_SIZE, MUTATION_SCOPE_DO_NOT_HYDRATE, } from "../../../common/constants"; -import * as FileSystem from "expo-file-system/legacy"; -import { Buffer } from "buffer"; import MediaLoading from "../../../components/MediaLoading"; import { useNetInfoContext } from "../../../contexts/net-info-banner/NetInfoContext"; import { @@ -259,13 +257,12 @@ const ReportIssue = () => { throw new Error("Upload aborted"); } - const chunk = await FileSystem.readAsStringAsync(filePath, { - length: MULTIPART_FILE_UPLOAD_SIZE, - position: index * MULTIPART_FILE_UPLOAD_SIZE, - encoding: FileSystem.EncodingType.Base64, + const data = await uploadS3Chunk({ + url, + filePath, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, }); - const buffer = Buffer.from(chunk, "base64"); - const data = await uploadS3Chunk({ url, chunk: buffer }); setUploadProgress( `${t("upload.progress")} ${Math.ceil(((uploadedPartsNo + index) / totalParts) * 100)}%`, ); diff --git a/mobile/components/ReviewCitizenFormSheet.tsx b/mobile/components/ReviewCitizenFormSheet.tsx index 82f4688a5..1e301d0f3 100644 --- a/mobile/components/ReviewCitizenFormSheet.tsx +++ b/mobile/components/ReviewCitizenFormSheet.tsx @@ -26,8 +26,6 @@ import { useUploadAttachmentCitizenMutation, } from "../services/mutations/citizen/add-attachment-citizen.mutation"; import { AddAttachmentCitizenStartAPIPayload } from "../services/api/citizen/attachments.api"; -import * as FileSystem from "expo-file-system/legacy"; -import { Buffer } from "buffer"; import * as Sentry from "@sentry/react-native"; import MediaLoading from "./MediaLoading"; import { @@ -298,14 +296,12 @@ export default function ReviewCitizenFormSheet({ if (cancelRef.current) { throw new Error("Upload aborted"); } - const chunk = await FileSystem.readAsStringAsync(filePath, { - length: MULTIPART_FILE_UPLOAD_SIZE, - position: index * MULTIPART_FILE_UPLOAD_SIZE, - encoding: FileSystem.EncodingType.Base64, + const data = await uploadS3Chunk({ + url, + filePath, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, }); - const buffer = Buffer.from(chunk, "base64"); - - const data = await uploadS3Chunk({ url, chunk: buffer }); setUploadProgress( `${t("attachments.upload.progress")} ${Math.ceil(((uploadedPartsNo + index) / totalParts) * 100)}%`, diff --git a/mobile/services/api/add-attachment.api.ts b/mobile/services/api/add-attachment.api.ts index 619d49535..d7a1d865c 100644 --- a/mobile/services/api/add-attachment.api.ts +++ b/mobile/services/api/add-attachment.api.ts @@ -1,6 +1,7 @@ import { FileMetadata } from "../../hooks/useCamera"; import API from "../api"; -import axios from "axios"; +import { File, Paths } from "expo-file-system"; +import { FileSystemUploadType, uploadAsync } from "expo-file-system/legacy"; /** ======================================================================== ================= POST addAttachment ==================== @@ -102,17 +103,63 @@ export const addAttachmentMultipartAbort = async ({ ).then((res) => res.data); }; -// Upload S3 Chunk of bytes (Buffer (array of bytes) - not Base64 - still bytes but written differently) -export const uploadS3Chunk = async (url: string, chunk: any): Promise<{ ETag: string }> => { - return axios - .put(url, chunk, { - timeout: 100000, - headers: { - "Content-Type": "application/octet-stream", - }, - }) - .then((res) => { - console.log(res); - return { ETag: res.headers.etag }; +// Upload one part of a multipart upload straight from disk. The bytes are uploaded natively by +// FileSystem.uploadAsync, so the chunk never goes through JS as a base64 string (which needed +// ~3-4x the chunk size in JS memory and crashed low-end phones on video uploads). +export type UploadS3ChunkPayload = { + url: string; + filePath: string; + partIndex: number; + partSize: number; +}; + +export const uploadS3Chunk = async ({ + url, + filePath, + partIndex, + partSize, +}: UploadS3ChunkPayload): Promise<{ ETag: string }> => { + const source = new File(filePath); + const fileSize = source.size; + const start = partIndex * partSize; + const isWholeFile = start === 0 && fileSize <= partSize; + + // Single-part files are uploaded as-is. For multi-part, copy the byte range to a temp file + // (native buffer, no base64) and upload that. + let partFile: File | null = null; + if (!isWholeFile) { + const handle = source.open(); + try { + handle.offset = start; + const bytes = handle.readBytes(Math.min(partSize, fileSize - start)); + partFile = new File(Paths.cache, `upload-part-${Date.now()}-${partIndex}`); + partFile.create({ overwrite: true }); + partFile.write(bytes); + } finally { + handle.close(); + } + } + + try { + const response = await uploadAsync(url, partFile ? partFile.uri : source.uri, { + httpMethod: "PUT", + uploadType: FileSystemUploadType.BINARY_CONTENT, + headers: { "Content-Type": "application/octet-stream" }, }); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`S3 part upload failed with status ${response.status}`); + } + + const etagKey = Object.keys(response.headers).find((key) => key.toLowerCase() === "etag"); + const ETag = etagKey ? response.headers[etagKey] : undefined; + if (!ETag) { + throw new Error("S3 part upload response has no ETag header"); + } + return { ETag }; + } finally { + if (partFile?.exists) { + partFile.delete(); + } + } }; diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index df48cf555..762200777 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -7,6 +7,7 @@ import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, addAttachmentMultipartStart, + UploadS3ChunkPayload, uploadS3Chunk, } from "../../api/add-attachment.api"; import { AttachmentsKeys } from "../../queries/attachments.query"; @@ -66,8 +67,7 @@ export const useUploadS3ChunkMutation = () => { id: MUTATION_SCOPE_DO_NOT_HYDRATE, }, mutationKey: AttachmentsKeys.addS3ChunkMutation(), - mutationFn: (payload: { url: string; chunk: any }): Promise<{ ETag: string }> => - uploadS3Chunk(payload.url, payload.chunk), + mutationFn: (payload: UploadS3ChunkPayload) => uploadS3Chunk(payload), retry: 3, }); }; From 12467cc55fd9f51661fb508d7939a541c898e510 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Fri, 6 Nov 2026 15:22:22 +0200 Subject: [PATCH 12/21] Mobile: build the shared Button on XStack so its pressed look does not stick on Android On Android, tamagui's Button kept its internal pressed state after the press ended (e.g. a submit that only shows validation errors), so the button stayed drawn faded with a grey border, or grey like a disabled one, until the screen was left. Build the Button on a plain XStack and drive the pressed and disabled looks from our own state and props; the pressed look is also cleared in onPress, as onPressOut is not always delivered. --- mobile/components/Button.tsx | 152 +++++++++++++-------- mobile/components/SelectPollingStation.tsx | 1 - 2 files changed, 97 insertions(+), 56 deletions(-) diff --git a/mobile/components/Button.tsx b/mobile/components/Button.tsx index 33962538e..f31b1d6d7 100644 --- a/mobile/components/Button.tsx +++ b/mobile/components/Button.tsx @@ -1,80 +1,73 @@ -import React from "react"; -import { TextStyle } from "react-native"; -import { - Button as TamaguiButton, - ButtonProps as TamaguiButtonProps, - styled, - useTheme, -} from "tamagui"; +import React, { useState } from "react"; +import { GestureResponderEvent, TextStyle } from "react-native"; +import { GetProps, styled, useTheme, XStack } from "tamagui"; import { Typography } from "./Typography"; type PresetType = "default" | "outlined" | "chromeless" | "red" | "yellow"; -const StyledButton = styled(TamaguiButton, { - name: "StyledButton", +/* + Built on a plain XStack instead of tamagui's Button on purpose: on Android, tamagui's Button kept + its internal "pressed" state after the press ended and stayed drawn with the pressed theme + (faded, with a grey border, or grey like a disabled button) until the screen was left. + The pressed and disabled looks are therefore plain variants, driven by our own state and props. +*/ +const ButtonFrame = styled(XStack, { + name: "AppButton", + role: "button", + focusable: true, + flexDirection: "row", + flexWrap: "nowrap", + justifyContent: "center", + alignItems: "center", + gap: "$xs", + height: "$true", paddingHorizontal: "$md", paddingVertical: "$xs", borderRadius: 8, + borderWidth: 1, + borderColor: "transparent", backgroundColor: "$purple5", - alignItems: "center", - disabledStyle: { - backgroundColor: "$gray3", - }, variants: { presets: { - default: { - pressStyle: { - backgroundColor: "$purple5", - opacity: 0.8, - }, - }, + default: {}, outlined: { - borderWidth: 1, borderColor: "$purple5", backgroundColor: "transparent", - pressStyle: { - backgroundColor: "transparent", - opacity: 0.8, - borderColor: "$purple7", - }, - disabledStyle: { - backgroundColor: "transparent", - opacity: 0.5, - }, }, chromeless: { backgroundColor: "transparent", - pressStyle: { - backgroundColor: "transparent", - opacity: 0.8, - borderColor: "transparent", - }, - disabledStyle: { - backgroundColor: "transparent", - opacity: 0.5, - }, }, red: { backgroundColor: "$red10", - pressStyle: { - backgroundColor: "$red10", - opacity: 0.8, - }, }, yellow: { backgroundColor: "$yellow5", - color: "$purple6", - pressStyle: { - backgroundColor: "$yellow5", - opacity: 0.8, - borderColor: "$yellow5", - }, + }, + }, + isPressed: { + true: { + opacity: 0.8, + }, + }, + // filled presets turn grey, the transparent ones fade + disabledLook: { + filled: { + backgroundColor: "$gray3", + borderColor: "transparent", + }, + transparent: { + opacity: 0.5, }, }, } as const, }); -export interface ButtonProps extends TamaguiButtonProps { +type ButtonFrameProps = GetProps; + +export interface ButtonProps extends Omit< + ButtonFrameProps, + "presets" | "isPressed" | "disabledLook" | "children" +> { children: string; /** * One of the different types of button presets. @@ -84,19 +77,35 @@ export interface ButtonProps extends TamaguiButtonProps { * Optional styling for overriding presetType text styling */ textStyle?: TextStyle; + /** + * Shown before the text + */ + icon?: React.ReactNode | (() => React.ReactNode); + disabled?: boolean; } /** * Button components which supports 3 initial presets: filled, outlined and chromeless - * This component is a HOC over the Tamagui Button. * @param {ButtonProps} props - The props for the `Button` component. * @returns {JSX.Element} The rendered `Button` component. */ const Button = (props: ButtonProps): React.ReactElement => { const theme = useTheme(); + const [isPressed, setIsPressed] = useState(false); - const { style: $styleOverride, children, textStyle, ...rest } = props; - const presetType: PresetType = props.preset ?? "default"; + const { + style: $styleOverride, + children, + textStyle, + icon, + preset, + disabled, + onPress, + onPressIn, + onPressOut, + ...rest + } = props; + const presetType: PresetType = preset ?? "default"; const $presetTextStyles = { color: presetType === "default" || presetType === "red" ? "white" : theme.$purple5?.val, }; @@ -104,11 +113,44 @@ const Button = (props: ButtonProps): React.ReactElement => { const $textStyles: TextStyle = { ...$presetTextStyles, ...textStyle }; return ( - + { + // onPressOut is not always delivered on Android when the press re-renders the screen, + // onPress is, so the pressed look is also cleared here + setIsPressed(false); + onPress?.(e); + } + } + onPressIn={(e: GestureResponderEvent) => { + setIsPressed(true); + onPressIn?.(e); + }} + onPressOut={(e: GestureResponderEvent) => { + setIsPressed(false); + onPressOut?.(e); + }} + {...rest} + > + {typeof icon === "function" ? icon() : icon} {children} - + ); }; diff --git a/mobile/components/SelectPollingStation.tsx b/mobile/components/SelectPollingStation.tsx index 431d2a2dd..581c98e55 100644 --- a/mobile/components/SelectPollingStation.tsx +++ b/mobile/components/SelectPollingStation.tsx @@ -108,7 +108,6 @@ const SelectPollingStation = () => { @@ -73,10 +128,48 @@ const QuickReport = () => { false )} + {isReportTypeSheetOpen && ( + + + + {t("report_type_sheet.title", { ns: "incident_report" })} + + {/* quick report is always the first option */} + + {t("report_type_sheet.quick_report", { ns: "incident_report" })} + + {incidentReportingForms?.map((form: FormAPIModel) => ( + selectIncidentForm(form)} + > + {getFormDisplayName(form)} + + ))} + + + )} + + {formToSelectLanguage && ( + openIncidentReport(formToSelectLanguage, language)} + onCancel={() => setFormToSelectLanguage(null)} + /> + )} + {isOpenInfoModal && ( setIsOpenInfoModal(false)} /> )} @@ -85,27 +178,52 @@ const QuickReport = () => { interface QuickReportContentProps { quickReports: QuickReportsAPIResponse[]; + incidentReportingForms: FormAPIModel[] | undefined; isLoading: boolean; error: Error | null; - activeElectionRound: ElectionRoundVM | undefined; + electionRoundId: string | undefined; + onAddReport: () => void; } const QuickReportContent = ({ quickReports, + incidentReportingForms, isLoading, error, - activeElectionRound, + electionRoundId, + onAddReport, }: QuickReportContentProps) => { const { t } = useTranslation(["quick_report", "common"]); const { isOnline } = useNetInfoContext(); + const { visits } = useUserData(); const [isRefreshing, setIsRefreshing] = useState(false); - const { refetch } = useQuickReports(activeElectionRound?.id); + const { refetch: refetchQuickReports } = useQuickReports(electionRoundId); + // a failing incident reports request should not hide the quick reports + const { data: incidentReports, refetch: refetchIncidentReports } = + useIncidentReports(electionRoundId); + + const reports: ReportListItem[] = useMemo( + () => + [ + ...quickReports.map((report): ReportListItem => ({ + kind: "quick-report", + timestamp: report.timestamp, + report, + })), + ...(incidentReports || []).map((report): ReportListItem => ({ + kind: "incident-report", + timestamp: report.isNotSynched ? undefined : report.timestamp, + report, + })), + ].sort(byTimestampDesc), + [quickReports, incidentReports], + ); const handleRefetch = () => { setIsRefreshing(true); - refetch().then(() => { + Promise.all([refetchQuickReports(), refetchIncidentReports()]).then(() => { setIsRefreshing(false); }); }; @@ -124,8 +242,8 @@ const QuickReportContent = ({ return ( - - data={quickReports} + + data={reports} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 16 }} ListEmptyComponent={ @@ -141,7 +259,7 @@ const QuickReportContent = ({ width="100%" height="100%" textStyle={{ textAlign: "center" }} - onPress={() => router.push("/report-issue")} + onPress={onAddReport} backgroundColor="white" > {t("list.add")} @@ -151,16 +269,31 @@ const QuickReportContent = ({ } bounces={isOnline} - renderItem={({ item, index }) => ( - router.push(`/report-details/${item.id}?reportTitle=${item.title}`)} - /> - )} + renderItem={({ item }) => + item.kind === "quick-report" ? ( + + router.push(`/report-details/${item.report.id}?reportTitle=${item.report.title}`) + } + /> + ) : ( + form.id === item.report.formId), + )} + location={getIncidentReportLocationLabel(item.report, visits)} + numberOfAnswers={item.report.answers.length} + numberOfAttachments={item.report.attachments.length} + onPress={() => router.push(`/incident-report-details/${item.report.id}`)} + /> + ) + } refreshControl={} /> diff --git a/mobile/app/(observer)/(app)/_layout.tsx b/mobile/app/(observer)/(app)/_layout.tsx index 4a84d9fa5..b9f403ddf 100644 --- a/mobile/app/(observer)/(app)/_layout.tsx +++ b/mobile/app/(observer)/(app)/_layout.tsx @@ -31,6 +31,10 @@ const AppLayout = () => { options={{ headerShown: false, gestureEnabled: false }} /> + diff --git a/mobile/app/(observer)/(app)/incident-report.tsx b/mobile/app/(observer)/(app)/incident-report.tsx new file mode 100644 index 000000000..74740a361 --- /dev/null +++ b/mobile/app/(observer)/(app)/incident-report.tsx @@ -0,0 +1,988 @@ +import * as Sentry from "@sentry/react-native"; +import { onlineManager, useQueryClient } from "@tanstack/react-query"; +import * as Crypto from "expo-crypto"; +import * as DocumentPicker from "expo-document-picker"; +import { router, useLocalSearchParams } from "expo-router"; +import { TFunction } from "i18next"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { BackHandler, Keyboard, ViewStyle } from "react-native"; +import Toast from "react-native-toast-message"; +import { Spinner, XStack, YStack } from "tamagui"; +import { PollingStationVisitVM } from "../../../common/models/polling-station.model"; +import { + MULTIPART_FILE_UPLOAD_SIZE, + MUTATION_SCOPE_DO_NOT_HYDRATE, +} from "../../../common/constants"; +import AddAttachment from "../../../components/AddAttachment"; +import Card from "../../../components/Card"; +import FormElement from "../../../components/FormInputs/FormElement"; +import FormInput from "../../../components/FormInputs/FormInput"; +import Header from "../../../components/Header"; +import { Icon } from "../../../components/Icon"; +import LinearProgress from "../../../components/LinearProgress"; +import { MediaDialog } from "../../../components/MediaDialog"; +import MediaLoading from "../../../components/MediaLoading"; +import NoteForm from "../../../components/NoteForm"; +import OptionsSheet from "../../../components/OptionsSheet"; +import QuestionForm from "../../../components/QuestionForm"; +import ReviewIncidentReportSheet from "../../../components/ReviewIncidentReportSheet"; +import { Screen } from "../../../components/Screen"; +import { ScreenScrollView } from "../../../components/ScreenScrollView"; +import Select from "../../../components/Select"; +import { Typography } from "../../../components/Typography"; +import WarningDialog from "../../../components/WarningDialog"; +import WizzardControls from "../../../components/WizzardControls"; +import { useNetInfoContext } from "../../../contexts/net-info-banner/NetInfoContext"; +import { useUserData } from "../../../contexts/user/UserContext.provider"; +import { FileMetadata, useCamera } from "../../../hooks/useCamera"; +import { AttachmentData } from "../../../services/api/add-attachment.api"; +import { AttachmentMimeType } from "../../../services/api/get-attachments.api"; +import { + IncidentReportAttachmentAPIResponse, + IncidentReportLocationType, +} from "../../../services/api/incident-report/incident-report.api"; +import { + mapFormSubmissionDataToAPIFormSubmissionAnswer, + removeAnswersForHiddenQuestions, + setFormDefaultValues, + shouldDisplayQuestion, + withAnswerTimestamps, +} from "../../../services/form.parser"; +import { ApiFormAnswer } from "../../../services/interfaces/answer.type"; +import { ApiFormQuestion } from "../../../services/interfaces/question.type"; +import { + removeMutationByScopeId, + useUploadS3ChunkMutation, +} from "../../../services/mutations/attachments/add-attachment.mutation"; +import { + useUploadAttachmentIncidentReportAbortMutation, + useUploadAttachmentIncidentReportCompleteMutation, + useUploadAttachmentIncidentReportMutation, +} from "../../../services/mutations/incident-report/add-attachment-incident-report.mutation"; +import { + useAddIncidentReport, + useAddIncidentReportNote, +} from "../../../services/mutations/incident-report/add-incident-report.mutation"; +import { useFormById } from "../../../services/queries/forms.query"; +import { + getFormDisplayName, + getFormLanguage, + getIncidentReportLocationLabel, +} from "../../../helpers/incidentReport"; +import { IncidentReportKeys } from "../../../services/queries/incident-reports.query"; + +type LocalNote = { id: string; text: string }; + +type LocationFormType = { + // a visited polling station id, or one of the OtherPollingStation / NotRelatedToAPollingStation location types + location: string; + locationDescription: string; +}; + +const mapVisitsToLocationOptions = (visits: PollingStationVisitVM[] = [], t: TFunction) => [ + ...visits.map((visit) => ({ + id: visit.pollingStationId, + value: visit.pollingStationId, + label: `${visit.number} - ${visit.address}`, + })), + { + id: "other", + value: IncidentReportLocationType.OtherPollingStation, + label: t("form.polling_station_id.options.other", { ns: "report_new_issue" }), + }, + { + id: "not_related_to_polling_station", + value: IncidentReportLocationType.NotRelatedToAPollingStation, + label: t("form.polling_station_id.options.not_related", { ns: "report_new_issue" }), + }, +]; + +const isLocationType = (location: string) => + location === IncidentReportLocationType.OtherPollingStation || + location === IncidentReportLocationType.NotRelatedToAPollingStation; + +const IncidentReport = () => { + const { formId, language: languageParam } = useLocalSearchParams<{ + formId: string; + language?: string; + }>(); + const { t } = useTranslation([ + "incident_report", + "report_new_issue", + "polling_station_form_wizard", + ]); + const queryClient = useQueryClient(); + const cancelRef = useRef(false); + + const { visits, activeElectionRound } = useUserData(); + const { isOnline } = useNetInfoContext(); + const { uploadCameraOrMedia } = useCamera(); + + const { data: currentForm, isLoading: isLoadingForm } = useFormById( + activeElectionRound?.id, + formId, + ); + + // the report id is generated once, so retrying a submission upserts the same report + const [incidentReportId] = useState(Crypto.randomUUID()); + const [step, setStep] = useState<"location" | "questions">("location"); + const [questionId, setQuestionId] = useState(); + const [answers, setAnswers] = useState>({}); + const [attachments, setAttachments] = useState>({}); + // notes are kept here until the report is sent + const [notes, setNotes] = useState>({}); + // when set, the options sheet shows the note form (a new note, or editing the one with noteId) + const [noteEditor, setNoteEditor] = useState<{ noteId?: string; text?: string } | null>(null); + const [selectedNoteToDelete, setSelectedNoteToDelete] = useState(null); + + const [isReviewSheetOpen, setIsReviewSheetOpen] = useState(false); + const [showWarningDialog, setShowWarningDialog] = useState(false); + const [isOptionsSheetOpen, setIsOptionsSheetOpen] = useState(false); + const [isPreparingFile, setIsPreparingFile] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); + const [selectedAttachmentToDelete, setSelectedAttachmentToDelete] = useState(null); + const [previewAttachment, setPreviewAttachment] = useState(null); + + const { + mutate: addIncidentReport, + mutateAsync: addIncidentReportAsync, + isPending, + } = useAddIncidentReport(incidentReportId); + const { mutate: addIncidentReportNote } = useAddIncidentReportNote(incidentReportId); + const { mutateAsync: initAttachment } = useUploadAttachmentIncidentReportMutation(); + const { mutateAsync: completeAttachment } = useUploadAttachmentIncidentReportCompleteMutation(); + const { mutateAsync: abortAttachment } = useUploadAttachmentIncidentReportAbortMutation(); + const { mutateAsync: uploadS3Chunk } = useUploadS3ChunkMutation(); + + const locationOptions = useMemo(() => mapVisitsToLocationOptions(visits, t), [visits, t]); + + const { + control: locationControl, + handleSubmit: handleLocationSubmit, + watch: watchLocation, + getValues: getLocationValues, + formState: { errors: locationErrors, isDirty: isLocationDirty }, + } = useForm({ + defaultValues: { location: "", locationDescription: "" }, + }); + const locationWatch = watchLocation("location"); + + // the language picked when choosing the form, if the form still has it + const language = useMemo( + () => + languageParam && currentForm?.languages.includes(languageParam) + ? languageParam + : getFormLanguage(currentForm), + [currentForm, languageParam], + ); + const formTitle = useMemo( + () => (currentForm ? `${getFormDisplayName(currentForm, language)} (${language})` : ""), + [currentForm, language], + ); + + const displayedQuestions: ApiFormQuestion[] = useMemo( + () => currentForm?.questions?.filter((q) => shouldDisplayQuestion(q, answers)) || [], + [currentForm, answers], + ); + + const activeQuestion = useMemo(() => { + const question = currentForm?.questions.find((q) => q.id === questionId); + if (!currentForm || !question) return undefined; + return { + indexInAllQuestions: currentForm.questions.findIndex((q) => q.id === questionId), + indexInDisplayedQuestions: displayedQuestions.findIndex((q) => q.id === questionId), + question, + }; + }, [currentForm, displayedQuestions, questionId]); + + const { + control: questionControl, + handleSubmit: handleQuestionSubmit, + reset: resetQuestionForm, + formState: { isDirty: isQuestionDirty }, + } = useForm(); + + // one screen walks through all the questions, so load the saved answer whenever the question changes + useEffect(() => { + if (questionId) { + resetQuestionForm(setFormDefaultValues(questionId, answers[questionId])); + } + }, [questionId]); + + const hasProgress = + isLocationDirty || + isQuestionDirty || + Object.keys(answers).length > 0 || + Object.values(attachments).some((a) => a.length > 0) || + Object.values(notes).some((n) => n.length > 0); + + const handleGoBack = () => { + if (hasProgress) { + Keyboard.dismiss(); + setShowWarningDialog(true); + } else { + router.back(); + } + }; + + useEffect(() => { + const backHandler = BackHandler.addEventListener("hardwareBackPress", () => { + handleGoBack(); + return true; + }); + return () => backHandler.remove(); + }, [hasProgress]); + + const getLocation = () => { + const { location, locationDescription } = getLocationValues(); + return isLocationType(location) + ? { + locationType: location as IncidentReportLocationType, + locationDescription, + } + : { locationType: IncidentReportLocationType.PollingStation, pollingStationId: location }; + }; + + const findNextQuestion = ( + index: number, + updatedAnswers: Record, + ): ApiFormQuestion | null => { + if (!currentForm || index + 1 >= currentForm.questions.length) return null; + const nextQuestion = currentForm.questions[index + 1]; + if (shouldDisplayQuestion(nextQuestion, updatedAnswers)) return nextQuestion; + return findNextQuestion(index + 1, updatedAnswers); + }; + + const findPreviousQuestion = (index: number): ApiFormQuestion | null => { + if (!currentForm || index - 1 < 0) return null; + const prevQuestion = currentForm.questions[index - 1]; + if (shouldDisplayQuestion(prevQuestion, answers)) return prevQuestion; + return findPreviousQuestion(index - 1); + }; + + const onSubmitLocation = () => { + const firstQuestion = findNextQuestion(-1, answers); + if (!firstQuestion) { + // a form without questions: nothing to fill in, go straight to the review + Keyboard.dismiss(); + setIsReviewSheetOpen(true); + return; + } + setQuestionId((current) => current ?? firstQuestion.id); + setStep("questions"); + }; + + const goToPrevious = () => { + if (!activeQuestion) return; + const prevQuestion = findPreviousQuestion(activeQuestion.indexInAllQuestions); + if (prevQuestion) { + setQuestionId(prevQuestion.id); + } else { + setStep("location"); + } + }; + + const onSubmitAnswer = (formValues: any) => { + if (!activeQuestion) return; + const id = activeQuestion.question.id; + + const updatedAnswer = withAnswerTimestamps( + mapFormSubmissionDataToAPIFormSubmissionAnswer( + id, + activeQuestion.question.$questionType, + formValues[id], + ), + answers[id], + ); + + // drop the answers of dependent questions that are no longer displayed + const updatedAnswers = removeAnswersForHiddenQuestions(currentForm?.questions, { + ...answers, + [id]: updatedAnswer, + }) as Record; + setAnswers(updatedAnswers); + + const nextQuestion = findNextQuestion(activeQuestion.indexInAllQuestions, updatedAnswers); + if (nextQuestion) { + setQuestionId(nextQuestion.id); + } else { + Keyboard.dismiss(); + setIsReviewSheetOpen(true); + } + }; + + const uploadAttachment = async ( + attachment: AttachmentData, + questionId: string, + uploadedPartsNo: number, + totalParts: number, + ) => { + if (!activeElectionRound || !currentForm) return; + + const { uploadId, uploadUrls } = await initAttachment({ + id: attachment.id, + electionRoundId: activeElectionRound.id, + incidentReportId, + formId: currentForm.id, + questionId, + fileName: attachment.fileMetadata.name, + contentType: attachment.fileMetadata.type, + numberOfUploadParts: Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE), + }); + + try { + let etags: Record = {}; + for (const [index, url] of Object.values(uploadUrls).entries()) { + if (cancelRef.current) { + throw new Error("Upload aborted"); + } + const data = await uploadS3Chunk({ + url, + filePath: attachment.fileMetadata.uri, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, + }); + setUploadProgress( + `${t("upload.progress", { ns: "report_new_issue" })} ${Math.ceil(((uploadedPartsNo + index) / totalParts) * 100)}%`, + ); + etags = { ...etags, [index + 1]: data.ETag }; + } + + await completeAttachment({ + uploadId, + etags, + electionRoundId: activeElectionRound.id, + id: attachment.id, + incidentReportId, + }); + + setAttachments((current) => ({ + ...current, + [questionId]: current[questionId].map((a) => + a.id === attachment.id ? { ...a, uploaded: true } : a, + ), + })); + } catch (err) { + Sentry.captureException(err, { data: { incidentReportId, questionId } }); + setUploadProgress(t("upload.aborted", { ns: "report_new_issue" })); + await abortAttachment({ + id: attachment.id, + uploadId, + electionRoundId: activeElectionRound.id, + incidentReportId, + }); + } + }; + + const uploadAttachments = async () => { + // only the attachments of questions that are still displayed + const pending = displayedQuestions.flatMap((question) => + (attachments[question.id] || []) + .filter((attachment) => !attachment.uploaded) + .map((attachment) => ({ attachment, questionId: question.id })), + ); + if (!pending.length) return; + + setIsUploading(true); + cancelRef.current = false; + setUploadProgress(t("upload.starting", { ns: "report_new_issue" })); + + const totalParts = pending.reduce( + (acc, { attachment }) => + acc + Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE), + 0, + ); + let uploadedPartsNo = 0; + + for (const { attachment, questionId } of pending) { + if (cancelRef.current) break; + try { + await uploadAttachment(attachment, questionId, uploadedPartsNo, totalParts); + } catch (err) { + Sentry.captureException(err); + } + uploadedPartsNo += Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE); + } + + setUploadProgress(t("upload.completed", { ns: "report_new_issue" })); + setIsUploading(false); + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(activeElectionRound?.id), + }); + }; + + const onSubmitReport = async () => { + if (!activeElectionRound || !currentForm) return; + + const optimisticAttachments: IncidentReportAttachmentAPIResponse[] = displayedQuestions.flatMap( + (question) => + (attachments[question.id] || []).map((attachment) => ({ + submissionId: incidentReportId, + questionId: question.id, + fileName: attachment.fileMetadata.name, + mimeType: attachment.fileMetadata.type, + presignedUrl: attachment.fileMetadata.uri, + urlValidityInSeconds: 0, + })), + ); + + const payload = { + id: incidentReportId, + electionRoundId: activeElectionRound.id, + formId: currentForm.id, + ...getLocation(), + answers: Object.values(answers).filter(Boolean) as ApiFormAnswer[], + isCompleted: true, + lastUpdatedAt: new Date().toISOString(), + attachments: optimisticAttachments, + notes: displayedQuestions.flatMap((question) => + (notes[question.id] || []).map((note) => ({ + submissionId: incidentReportId, + questionId: question.id, + text: note.text, + timeSubmitted: new Date().toISOString(), + })), + ), + }; + + // the notes share the report's mutation scope, so they are sent (also after being offline) + // only once the report was created + const sendNotes = () => { + displayedQuestions.forEach((question) => + (notes[question.id] || []).forEach((note) => + addIncidentReportNote({ + id: note.id, + electionRoundId: activeElectionRound.id, + incidentReportId, + formId: currentForm.id, + questionId: question.id, + text: note.text, + }), + ), + ); + }; + + const onError = () => { + Toast.show({ type: "error", text2: t("error") }); + }; + + if (!optimisticAttachments.length) { + // like quick reports: without attachments the report is queued while offline and sent later + addIncidentReport(payload, { + onError, + onSuccess: () => { + setIsReviewSheetOpen(false); + router.back(); + }, + }); + sendNotes(); + if (!onlineManager.isOnline()) { + setIsReviewSheetOpen(false); + router.back(); + } + return; + } + + // attachments reference the incident report, so it has to exist on the server before uploading them + if (!onlineManager.isOnline()) { + Toast.show({ + type: "error", + text2: t("upload.offline", { ns: "report_new_issue" }), + visibilityTime: 5000, + text2Style: { textAlign: "center" }, + }); + return; + } + + try { + await addIncidentReportAsync(payload); + } catch { + onError(); + return; + } + sendNotes(); + + await uploadAttachments(); + + if (!cancelRef.current) { + setIsReviewSheetOpen(false); + router.back(); + } + }; + + const onAbortUpload = () => { + // the report itself is already sent, only the remaining attachments are dropped + cancelRef.current = true; + setIsUploading(false); + setUploadProgress(""); + removeMutationByScopeId(queryClient, MUTATION_SCOPE_DO_NOT_HYDRATE); + setIsReviewSheetOpen(false); + router.back(); + }; + + const addLocalAttachment = (fileMetadata: FileMetadata) => { + if (!questionId) return; + setAttachments((current) => ({ + ...current, + [questionId]: [ + ...(current[questionId] || []), + { fileMetadata, id: Crypto.randomUUID(), uploaded: false }, + ], + })); + }; + + const removeLocalAttachment = (id: string) => { + if (!questionId) return; + setAttachments((current) => ({ + ...current, + [questionId]: (current[questionId] || []).filter((attachment) => attachment.id !== id), + })); + }; + + const handleCameraUpload = async (type: "library" | "cameraPhoto") => { + setIsPreparingFile(true); + setUploadProgress(t("upload.preparing", { ns: "report_new_issue" })); + const cameraResult = await uploadCameraOrMedia(type, (progress: number) => + setUploadProgress( + `${t("upload.compressing", { ns: "report_new_issue" })} ${Math.ceil(progress * 100)}%`, + ), + ); + if (cameraResult) { + setIsOptionsSheetOpen(false); + addLocalAttachment(cameraResult); + } + setIsPreparingFile(false); + }; + + const handleUploadAudio = async () => { + const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false }); + const file = doc?.assets?.[0]; + if (file) { + setIsOptionsSheetOpen(false); + addLocalAttachment({ + name: file.name, + type: file.mimeType || "audio/mpeg", + uri: file.uri, + size: file.size || 0, + }); + } + }; + + const saveLocalNote = (text: string) => { + if (!questionId || !noteEditor) return; + const { noteId } = noteEditor; + setNotes((current) => { + const questionNotes = current[questionId] || []; + return { + ...current, + [questionId]: noteId + ? questionNotes.map((note) => (note.id === noteId ? { ...note, text } : note)) + : [...questionNotes, { id: Crypto.randomUUID(), text }], + }; + }); + Keyboard.dismiss(); + setNoteEditor(null); + setIsOptionsSheetOpen(false); + }; + + const removeLocalNote = (id: string) => { + if (!questionId) return; + setNotes((current) => ({ + ...current, + [questionId]: (current[questionId] || []).filter((note) => note.id !== id), + })); + }; + + const openNoteEditor = (note?: LocalNote) => { + Keyboard.dismiss(); + setNoteEditor(note ? { noteId: note.id, text: note.text } : {}); + setIsOptionsSheetOpen(true); + }; + + const handleOnShowAttachmentSheet = () => { + Keyboard.dismiss(); + setIsOptionsSheetOpen(true); + }; + + // adding media needs a connection (the files are uploaded when the report is sent) + const whenOnline = (action: () => void) => () => { + if (isOnline) { + action(); + } else { + Toast.show({ + type: "error", + text2: t("upload.offline", { ns: "report_new_issue" }), + visibilityTime: 5000, + text2Style: { textAlign: "center" }, + }); + } + }; + + if (!formId) { + return Incorrect page params; + } + + if (isLoadingForm) { + return ( + + + + ); + } + + if (!currentForm) { + return ( + +
} + onLeftPress={() => router.back()} + /> + {t("form_not_found")} + + ); + } + + const questionAttachments = (questionId && attachments[questionId]) || []; + const questionNotes = (questionId && notes[questionId]) || []; + + return ( + + ) : ( + + ) + } + > +
} + onLeftPress={handleGoBack} + /> + + {step === "location" && ( + + ( + + - - ); - }} - /> - {errors.noteText && {errors.noteText.message}} - - - - - + setAddingNote(false)} + /> ); }; diff --git a/mobile/components/IncidentReportCard.tsx b/mobile/components/IncidentReportCard.tsx new file mode 100644 index 000000000..e16b851d4 --- /dev/null +++ b/mobile/components/IncidentReportCard.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { YStack } from "tamagui"; +import Card from "./Card"; +import CardFooter from "./CardFooter"; +import { Typography } from "./Typography"; + +interface IncidentReportCardProps { + formName: string; + location: string; + numberOfAnswers: number; + numberOfAttachments: number; + onPress?: () => void; +} + +const IncidentReportCard = ({ + formName, + location, + numberOfAnswers, + numberOfAttachments, + onPress, +}: IncidentReportCardProps): React.ReactElement => { + const { t } = useTranslation(["incident_report", "quick_report"]); + + return ( + + + {formName} + {location ? {location} : false} + {t("list.answers", { count: numberOfAnswers })} + + + + ); +}; + +export default IncidentReportCard; diff --git a/mobile/components/MultiSubmissionFormList.tsx b/mobile/components/MultiSubmissionFormList.tsx index ca13e4004..f1e7ae92e 100644 --- a/mobile/components/MultiSubmissionFormList.tsx +++ b/mobile/components/MultiSubmissionFormList.tsx @@ -20,6 +20,7 @@ import { } from "../services/queries.service"; import { useFormSubmissions } from "../services/queries/form-submissions.query"; import { useElectionRoundAllForms } from "../services/queries/forms.query"; +import { isIncidentReportingForm } from "../services/definitions.api"; import Button from "./Button"; import { Dialog } from "./Dialog"; import MultiSubmissionFormCard from "./MultiSubmissionFormCard"; @@ -38,10 +39,7 @@ export type MultiSubmissionFormListItem = { }; type ListHeaderComponentType = - | ComponentType - | ReactElement> - | null - | undefined; + ComponentType | ReactElement> | null | undefined; interface IMultiSubmissionFormListProps { ListHeaderComponent: ListHeaderComponentType; @@ -93,7 +91,10 @@ const MultiSubmissionFormList = ({ ListHeaderComponent }: IMultiSubmissionFormLi }; const formList: MultiSubmissionFormListItem[] = useMemo(() => { - return mapFormToMultiSubmissionFormListItem(allForms?.forms, formSubmissions); + return mapFormToMultiSubmissionFormListItem( + allForms?.forms.filter((form) => !isIncidentReportingForm(form)), + formSubmissions, + ); }, [allForms, formSubmissions]); const { diff --git a/mobile/components/NoteForm.tsx b/mobile/components/NoteForm.tsx new file mode 100644 index 000000000..bb2d8eb41 --- /dev/null +++ b/mobile/components/NoteForm.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { ScrollView, XStack, YStack } from "tamagui"; +import Button from "./Button"; +import Input from "./Inputs/Input"; +import { Typography } from "./Typography"; + +interface NoteFormProps { + heading: string; + initialText?: string; + onSave: (text: string) => void; + onCancel: () => void; +} + +const NoteForm = ({ heading, initialText = "", onSave, onCancel }: NoteFormProps) => { + const { t } = useTranslation(["polling_station_form_wizard", "common"]); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + noteText: initialText, + }, + }); + + const onSubmitNote = ({ noteText }: { noteText: string }) => { + onSave(noteText); + reset(); + }; + + return ( + + {heading} + + value.trim().length > 0 || t("notes.add.form.input.required"), + }} + render={({ field: { value: noteValue, onChange: onNoteChange } }) => { + return ( + + + + ); + }} + /> + {errors.noteText && {errors.noteText.message}} + + + + + + ); +}; + +export default NoteForm; diff --git a/mobile/components/ReviewCitizenFormSheet.tsx b/mobile/components/ReviewCitizenFormSheet.tsx index 1e301d0f3..773456088 100644 --- a/mobile/components/ReviewCitizenFormSheet.tsx +++ b/mobile/components/ReviewCitizenFormSheet.tsx @@ -4,13 +4,9 @@ import { Typography } from "./Typography"; import Button from "./Button"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FormAPIModel } from "../services/definitions.api"; -import { - ApiFormAnswer, - MultiSelectAnswer, - SingleSelectAnswer, -} from "../services/interfaces/answer.type"; +import { ApiFormAnswer } from "../services/interfaces/answer.type"; import { Separator, Sheet, Spinner, YStack } from "tamagui"; -import { getAnswerDisplay } from "../common/utils/answers"; +import { getAnswerDisplay, mapAnswersForDisplay } from "../common/utils/answers"; import { ApiFormQuestion } from "../services/interfaces/question.type"; import { useTranslation } from "react-i18next"; import * as Crypto from "expo-crypto"; @@ -91,66 +87,10 @@ export default function ReviewCitizenFormSheet({ const { mutateAsync: addAttachmentCitizen } = useUploadAttachmentCitizenMutation(); - const mappedAnswers = useMemo(() => { - if (!answers || !questions) return {}; - - return Object.entries(answers).reduce( - (acc, [questionId, answer]) => { - if (!answer) return acc; - const question = questions.find((q) => q.id === questionId); - if (!question) return acc; - - let mappedAnswer = { ...answer } as SingleSelectAnswer | MultiSelectAnswer; - - if ( - answer.$answerType === "singleSelectAnswer" && - question.$questionType === "singleSelectQuestion" - ) { - const selectedOptionId = answer.selection.optionId; - - const selectedOptionText = question.options.find((o) => o.id === selectedOptionId)?.text[ - language - ]; - - mappedAnswer = { - ...mappedAnswer, - selection: { - ...mappedAnswer.selection, - value: selectedOptionText, - }, - } as SingleSelectAnswer; - } else if ( - answer.$answerType === "multiSelectAnswer" && - question.$questionType === "multiSelectQuestion" - ) { - const selectedOptionIds = answer.selection.map((selection) => selection.optionId); - const selectedOptionsTexts: string[] = []; - for (const option of question.options) { - if (selectedOptionIds.includes(option.id)) { - const selectedOption = answer.selection.find( - (selection) => selection.optionId === option.id, - ); - const optionText = option.text[language] || ""; - const userText = selectedOption?.text || ""; - selectedOptionsTexts.push( - userText ? `${optionText} (${userText.trim()})` : optionText, - ); - } - } - - mappedAnswer = { - ...mappedAnswer, - selection: answer.selection, - selectionValues: selectedOptionsTexts, - } as MultiSelectAnswer; - } - - acc[questionId] = mappedAnswer; - return acc; - }, - {} as Record, - ); - }, [answers, questions]); + const mappedAnswers = useMemo( + () => mapAnswersForDisplay(answers, questions, language), + [answers, questions, language], + ); // only display the questions that have an answer to them (because in case of skip logic we would have multiple questions that were not shown to the user) const displayedQuestions = useMemo(() => { if (!currentForm?.questions || !mappedAnswers) return []; @@ -296,7 +236,7 @@ export default function ReviewCitizenFormSheet({ if (cancelRef.current) { throw new Error("Upload aborted"); } - const data = await uploadS3Chunk({ + const data = await uploadS3Chunk({ url, filePath, partIndex: index, diff --git a/mobile/components/ReviewIncidentReportSheet.tsx b/mobile/components/ReviewIncidentReportSheet.tsx new file mode 100644 index 000000000..cd73f702f --- /dev/null +++ b/mobile/components/ReviewIncidentReportSheet.tsx @@ -0,0 +1,165 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Separator, Sheet, Spinner, YStack } from "tamagui"; +import { getAnswerDisplay, mapAnswersForDisplay } from "../common/utils/answers"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; +import { AttachmentData } from "../services/api/add-attachment.api"; +import { FormAPIModel } from "../services/definitions.api"; +import { ApiFormAnswer } from "../services/interfaces/answer.type"; +import Button from "./Button"; +import { Icon } from "./Icon"; +import MediaLoading from "./MediaLoading"; +import { Typography } from "./Typography"; + +interface ReviewIncidentReportSheetProps { + currentForm: FormAPIModel; + language: string; + locationLabel: string; + answers: Record | undefined; + attachments: Record; + notes: Record; + isSubmitting: boolean; + isUploading: boolean; + uploadProgress: string; + onSubmit: () => void; + onAbortUpload: () => void; + onClose: () => void; +} + +const ReviewIncidentReportSheet = ({ + currentForm, + language, + locationLabel, + answers, + attachments, + notes, + isSubmitting, + isUploading, + uploadProgress, + onSubmit, + onAbortUpload, + onClose, +}: ReviewIncidentReportSheetProps) => { + const { t } = useTranslation(["incident_report", "polling_station_form_wizard"]); + const dateTimeFormat = useDateTimeFormat(); + const insets = useSafeAreaInsets(); + + const mappedAnswers = useMemo( + () => mapAnswersForDisplay(answers, currentForm.questions, language), + [answers, currentForm.questions, language], + ); + + // only the questions with an answer, notes or media (with skip logic, some were never shown) + const answeredQuestions = useMemo( + () => + currentForm.questions.filter( + (question) => + mappedAnswers[question.id] || + notes[question.id]?.length || + attachments[question.id]?.length, + ), + [currentForm.questions, mappedAnswers, notes, attachments], + ); + + const isBusy = isSubmitting || isUploading; + + return ( + { + if (!open) { + onClose(); + } + }} + > + + + {isUploading ? ( + + attachment.uploaded).length + } + onAbortUpload={onAbortUpload} + confirmAbort + /> + + ) : ( + <> + + + {isSubmitting ? ( + + + + {t("review.sending_report")} + + + ) : ( + + {t("review.heading")} + + + {t("location.label")} + + {locationLabel} + + + + + {answeredQuestions.map((question) => ( + + {question.text[language]} + {mappedAnswers[question.id] ? ( + + {getAnswerDisplay(mappedAnswers[question.id], true, dateTimeFormat)} + + ) : ( + false + )} + {notes[question.id]?.map((note) => ( + + {`${t("notes.heading", { ns: "polling_station_form_wizard" })}: ${note.text}`} + + ))} + {attachments[question.id]?.length ? ( + + {t("attachments.heading")}: {attachments[question.id].length} + + ) : ( + false + )} + + + ))} + + )} + + + + + + + )} + + + ); +}; + +export default ReviewIncidentReportSheet; diff --git a/mobile/components/SelectFormLanguageDialog.tsx b/mobile/components/SelectFormLanguageDialog.tsx new file mode 100644 index 000000000..151f07164 --- /dev/null +++ b/mobile/components/SelectFormLanguageDialog.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { XStack } from "tamagui"; +import Button from "./Button"; +import { Dialog } from "./Dialog"; +import SelectFormLanguageDialogContent from "./SelectFormLanguageDialogContent"; +import { Typography } from "./Typography"; + +interface SelectFormLanguageDialogProps { + languages: string[]; + onConfirm: (language: string) => void; + onCancel: () => void; +} + +// asks which language to fill in a form that is available in several languages +const SelectFormLanguageDialog = ({ + languages, + onConfirm, + onCancel, +}: SelectFormLanguageDialogProps) => { + const { t } = useTranslation(["observation", "common"]); + const { + control, + handleSubmit, + formState: { errors }, + } = useForm<{ language: string }>({ defaultValues: { language: "" } }); + + return ( + ( + {t("forms.select_language_modal.header")} + } + content={ + + } + footer={ + + + + + } + /> + )} + /> + ); +}; + +export default SelectFormLanguageDialog; diff --git a/mobile/components/SingleSubmissionFormList.tsx b/mobile/components/SingleSubmissionFormList.tsx index 7759ae3bf..039f42eb9 100644 --- a/mobile/components/SingleSubmissionFormList.tsx +++ b/mobile/components/SingleSubmissionFormList.tsx @@ -19,6 +19,7 @@ import { } from "../services/queries.service"; import { useFormSubmissions } from "../services/queries/form-submissions.query"; import { useElectionRoundAllForms } from "../services/queries/forms.query"; +import { isIncidentReportingForm } from "../services/definitions.api"; import Button from "./Button"; import { Dialog } from "./Dialog"; import SingleSubmissionFormCard from "./SingleSubmissionFormCard"; @@ -40,10 +41,7 @@ export type FormListItem = { }; type ListHeaderComponentType = - | ComponentType - | ReactElement> - | null - | undefined; + ComponentType | ReactElement> | null | undefined; interface ISingleSubmissionFormListProps { ListHeaderComponent: ListHeaderComponentType; @@ -95,7 +93,10 @@ const SingleSubmissionFormList = ({ ListHeaderComponent }: ISingleSubmissionForm }; const formList: FormListItem[] = useMemo(() => { - return mapFormToFormListItem(allForms?.forms, formSubmissions); + return mapFormToFormListItem( + allForms?.forms.filter((form) => !isIncidentReportingForm(form)), + formSubmissions, + ); }, [allForms, formSubmissions]); const { diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index d9d9c435c..c8552ff52 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -24,6 +24,13 @@ import { ASYNC_STORAGE_KEYS, MUTATION_SCOPE_DO_NOT_HYDRATE } from "../../common/ import * as Sentry from "@sentry/react-native"; import SuperJSON from "superjson"; import { QuickReportAttachmentAPIResponse } from "../../services/api/quick-report/get-quick-reports.api"; +import { IncidentReportKeys } from "../../services/queries/incident-reports.query"; +import { + upsertIncidentReport, + upsertIncidentReportNote, + UpsertIncidentReportNoteAPIPayload, +} from "../../services/api/incident-report/incident-report.api"; +import { AddIncidentReportMutationPayload } from "../../services/mutations/incident-report/add-incident-report.mutation"; const queryClient = new QueryClient({ mutationCache: new MutationCache({ @@ -144,6 +151,22 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { }, }); + queryClient.setMutationDefaults(IncidentReportKeys.add(), { + mutationFn: async ({ + attachments: _attachments, + notes: _notes, + ...payload + }: AddIncidentReportMutationPayload) => { + return upsertIncidentReport(payload); + }, + }); + + queryClient.setMutationDefaults(IncidentReportKeys.addNote(), { + mutationFn: (payload: UpsertIncidentReportNoteAPIPayload) => { + return upsertIncidentReportNote(payload); + }, + }); + const runPendingMutations = async () => { console.log( "PersistQueryClientProvider onSuccess - Successfully get data from AsyncStorage and put in MutationCache", diff --git a/mobile/helpers/incidentReport.ts b/mobile/helpers/incidentReport.ts new file mode 100644 index 000000000..9902d2885 --- /dev/null +++ b/mobile/helpers/incidentReport.ts @@ -0,0 +1,49 @@ +import i18n from "../common/config/i18n"; +import { PollingStationVisitVM } from "../common/models/polling-station.model"; +import { + IncidentReportAPIResponse, + IncidentReportLocationType, +} from "../services/api/incident-report/incident-report.api"; +import { FormAPIModel } from "../services/definitions.api"; + +// the app language if the form has it, otherwise the form default language +export const getFormLanguage = (form: FormAPIModel | undefined): string => { + const appLanguage = i18n.language.toUpperCase(); + if (!form || form.languages.includes(appLanguage)) return appLanguage; + return form.defaultLanguage || form.languages[0]; +}; + +export const getFormDisplayName = ( + form: FormAPIModel | undefined, + language: string = getFormLanguage(form), +): string => { + if (!form) return ""; + const name = + form.name[language] || form.name[form.defaultLanguage] || Object.values(form.name)[0]; + return `${form.code} - ${name}`; +}; + +export const getIncidentReportLocationLabel = ( + report: Pick< + IncidentReportAPIResponse, + "locationType" | "pollingStationId" | "locationDescription" + >, + visits: PollingStationVisitVM[] | undefined, +): string => { + switch (report.locationType) { + case IncidentReportLocationType.PollingStation: { + const visit = visits?.find((v) => v.pollingStationId === report.pollingStationId); + return visit ? `${visit.number} - ${visit.address}` : ""; + } + case IncidentReportLocationType.OtherPollingStation: + case IncidentReportLocationType.NotRelatedToAPollingStation: { + const label = + report.locationType === IncidentReportLocationType.OtherPollingStation + ? i18n.t("form.polling_station_id.options.other", { ns: "report_new_issue" }) + : i18n.t("form.polling_station_id.options.not_related", { ns: "report_new_issue" }); + return report.locationDescription ? `${label}: ${report.locationDescription}` : label; + } + default: + return ""; + } +}; diff --git a/mobile/services/api/incident-report/incident-report.api.ts b/mobile/services/api/incident-report/incident-report.api.ts new file mode 100644 index 000000000..4ab418d75 --- /dev/null +++ b/mobile/services/api/incident-report/incident-report.api.ts @@ -0,0 +1,174 @@ +import API from "../../api"; +import { ApiFormAnswer } from "../../interfaces/answer.type"; + +export enum IncidentReportLocationType { + NotRelatedToAPollingStation = "NotRelatedToAPollingStation", + OtherPollingStation = "OtherPollingStation", + PollingStation = "PollingStation", +} + +export type IncidentReportAttachmentAPIResponse = { + submissionId: string; + questionId: string; + fileName: string; + mimeType: string; + presignedUrl: string; + urlValidityInSeconds: number; + uploadedFileName?: string; + filePath?: string; + timeSubmitted?: string; +}; + +export type IncidentReportNoteAPIResponse = { + submissionId: string; + questionId: string; + text: string; + timeSubmitted: string; +}; + +export type IncidentReportAPIResponse = { + id: string; + formId: string; + locationType: IncidentReportLocationType; + pollingStationId?: string | null; + locationDescription?: string | null; + answers: ApiFormAnswer[]; + notes: IncidentReportNoteAPIResponse[]; + attachments: IncidentReportAttachmentAPIResponse[]; + timestamp?: string; + isCompleted: boolean; + isNotSynched?: boolean; +}; + +/** ======================================================================== + ================= GET my incident reports ==================== + ======================================================================== + @description Retrieves the incident reports of the current observer for an election round +*/ +export const getMyIncidentReports = ( + electionRoundId: string, +): Promise => { + return API.get<{ incidentReports: IncidentReportAPIResponse[] }>( + `election-rounds/${electionRoundId}/incident-reports:my`, + ).then((res) => res.data.incidentReports); +}; + +/** ======================================================================== + ================= POST incidentReport ==================== + ======================================================================== + @description Upsert an Incident Report (a submission of an IncidentReporting form) +*/ +export type UpsertIncidentReportAPIPayload = { + id: string; + electionRoundId: string; + formId: string; + locationType: IncidentReportLocationType; + pollingStationId?: string; + locationDescription?: string; + answers: ApiFormAnswer[]; + isCompleted?: boolean; + lastUpdatedAt: string; +}; + +export const upsertIncidentReport = ({ + electionRoundId, + ...payload +}: UpsertIncidentReportAPIPayload): Promise => { + return API.post(`election-rounds/${electionRoundId}/incident-reports`, payload).then( + (res) => res.data, + ); +}; + +/** ======================================================================== + ================= Incident report attachments (multipart upload) ====== + ======================================================================== +*/ +export type AddAttachmentIncidentReportStartAPIPayload = { + id: string; + electionRoundId: string; + incidentReportId: string; + formId: string; + questionId: string; + fileName: string; + contentType: string; + numberOfUploadParts: number; +}; + +export type AddAttachmentIncidentReportCompleteAPIPayload = { + uploadId: string; + id: string; + etags: Record; + incidentReportId: string; + electionRoundId: string; +}; + +export type AddAttachmentIncidentReportAbortAPIPayload = { + uploadId: string; + id: string; + incidentReportId: string; + electionRoundId: string; +}; + +export const addAttachmentIncidentReportMultipartStart = ({ + electionRoundId, + incidentReportId, + ...payload +}: AddAttachmentIncidentReportStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments:init`, + payload, + ).then((res) => res.data); +}; + +export const addAttachmentIncidentReportMultipartComplete = ({ + uploadId, + id, + etags, + incidentReportId, + electionRoundId, +}: AddAttachmentIncidentReportCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments/${id}:complete`, + { uploadId, etags }, + ).then((res) => res.data); +}; + +export const addAttachmentIncidentReportMultipartAbort = ({ + uploadId, + id, + incidentReportId, + electionRoundId, +}: AddAttachmentIncidentReportAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments/${id}:abort`, + { uploadId }, + ).then((res) => res.data); +}; + +/** ======================================================================== + ================= POST incidentReportNote ==================== + ======================================================================== + @description Upsert a note on a question of an Incident Report (the report has to exist) +*/ +export type UpsertIncidentReportNoteAPIPayload = { + id: string; + electionRoundId: string; + incidentReportId: string; + formId: string; + questionId: string; + text: string; +}; + +export const upsertIncidentReportNote = ({ + electionRoundId, + incidentReportId, + ...payload +}: UpsertIncidentReportNoteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/notes`, + payload, + ).then((res) => res.data); +}; diff --git a/mobile/services/api/quick-report/get-quick-reports.api.ts b/mobile/services/api/quick-report/get-quick-reports.api.ts index 42ab1d936..39b62d9b6 100644 --- a/mobile/services/api/quick-report/get-quick-reports.api.ts +++ b/mobile/services/api/quick-report/get-quick-reports.api.ts @@ -28,6 +28,7 @@ export type QuickReportsAPIResponse = { pollingStationId?: string | null; pollingStationDetails?: string; attachments: Array; + timestamp?: string; }; export const getQuickReports = ( diff --git a/mobile/services/definitions.api.ts b/mobile/services/definitions.api.ts index 4b8fe9783..77666d696 100644 --- a/mobile/services/definitions.api.ts +++ b/mobile/services/definitions.api.ts @@ -206,6 +206,20 @@ export const getPollingStationInformation = ( @returns {PollingStationInformationAPIResponse} */ +export enum FormType { + Opening = "Opening", + Voting = "Voting", + ClosingAndCounting = "ClosingAndCounting", + PSI = "PSI", + IncidentReporting = "IncidentReporting", + CitizenReporting = "CitizenReporting", + Other = "Other", +} + +// incident reporting forms are filled in from the report tab, not from the observation forms list +export const isIncidentReportingForm = (form: Pick) => + form.formType === FormType.IncidentReporting; + export type FormAPIModel = { id: string; formType: string; // "ClosingAndCounting", diff --git a/mobile/services/mutations/incident-report/add-attachment-incident-report.mutation.ts b/mobile/services/mutations/incident-report/add-attachment-incident-report.mutation.ts new file mode 100644 index 000000000..026060280 --- /dev/null +++ b/mobile/services/mutations/incident-report/add-attachment-incident-report.mutation.ts @@ -0,0 +1,52 @@ +import { useMutation } from "@tanstack/react-query"; +import * as Sentry from "@sentry/react-native"; +import { MUTATION_SCOPE_DO_NOT_HYDRATE } from "../../../common/constants"; +import { + AddAttachmentIncidentReportAbortAPIPayload, + AddAttachmentIncidentReportCompleteAPIPayload, + AddAttachmentIncidentReportStartAPIPayload, + addAttachmentIncidentReportMultipartAbort, + addAttachmentIncidentReportMultipartComplete, + addAttachmentIncidentReportMultipartStart, +} from "../../api/incident-report/incident-report.api"; +import { IncidentReportKeys } from "../../queries/incident-reports.query"; + +// Multipart Upload - Start +export const useUploadAttachmentIncidentReportMutation = () => { + return useMutation({ + mutationKey: IncidentReportKeys.addAttachment(), + scope: { + id: MUTATION_SCOPE_DO_NOT_HYDRATE, + }, + mutationFn: (payload: AddAttachmentIncidentReportStartAPIPayload) => + addAttachmentIncidentReportMultipartStart(payload), + onError: (err, variables) => { + Sentry.captureException(err, { data: { variables } }); + }, + retry: 3, + }); +}; + +export const useUploadAttachmentIncidentReportCompleteMutation = () => { + return useMutation({ + mutationKey: IncidentReportKeys.addAttachmentComplete(), + scope: { + id: MUTATION_SCOPE_DO_NOT_HYDRATE, + }, + mutationFn: (payload: AddAttachmentIncidentReportCompleteAPIPayload) => + addAttachmentIncidentReportMultipartComplete(payload), + retry: 3, + }); +}; + +export const useUploadAttachmentIncidentReportAbortMutation = () => { + return useMutation({ + mutationKey: IncidentReportKeys.addAttachmentAbort(), + scope: { + id: MUTATION_SCOPE_DO_NOT_HYDRATE, + }, + mutationFn: (payload: AddAttachmentIncidentReportAbortAPIPayload) => + addAttachmentIncidentReportMultipartAbort(payload), + retry: 3, + }); +}; diff --git a/mobile/services/mutations/incident-report/add-incident-report.mutation.ts b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts new file mode 100644 index 000000000..6a8f15ef5 --- /dev/null +++ b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts @@ -0,0 +1,80 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + IncidentReportAPIResponse, + IncidentReportAttachmentAPIResponse, + IncidentReportNoteAPIResponse, + UpsertIncidentReportAPIPayload, + UpsertIncidentReportNoteAPIPayload, +} from "../../api/incident-report/incident-report.api"; +import { IncidentReportKeys } from "../../queries/incident-reports.query"; + +// attachments and notes are only used for the optimistic update, they are sent separately +export type AddIncidentReportMutationPayload = UpsertIncidentReportAPIPayload & { + attachments: IncidentReportAttachmentAPIResponse[]; + notes: IncidentReportNoteAPIResponse[]; +}; + +// the mutationFns are set through setMutationDefaults (PersistQueryContext) so they can resume offline; +// the report and its notes share a scope, so the notes are only sent after the report was created +export const useAddIncidentReport = (incidentReportId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: IncidentReportKeys.add(), + scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + onMutate: async ({ + attachments, + notes, + electionRoundId, + ...payload + }: AddIncidentReportMutationPayload) => { + const queryKey = IncidentReportKeys.byElectionRound(electionRoundId); + // so an outgoing refetch does not overwrite the optimistic update + await queryClient.cancelQueries({ queryKey }); + + const previousReports = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old = []) => [ + ...old.filter((report) => report.id !== payload.id), + { + id: payload.id, + formId: payload.formId, + locationType: payload.locationType, + pollingStationId: payload.pollingStationId, + locationDescription: payload.locationDescription, + answers: payload.answers, + isCompleted: payload.isCompleted ?? false, + timestamp: payload.lastUpdatedAt, + notes, + attachments, + isNotSynched: true, + }, + ]); + + return { previousReports }; + }, + onError: (_err, variables, context) => { + const queryKey = IncidentReportKeys.byElectionRound(variables.electionRoundId); + queryClient.setQueryData(queryKey, context?.previousReports); + }, + onSettled: (_data, _err, variables) => { + return queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + }, + }); +}; + +export const useAddIncidentReportNote = (incidentReportId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: IncidentReportKeys.addNote(), + scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + onSettled: (_data, _err, variables: UpsertIncidentReportNoteAPIPayload) => { + return queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + }, + }); +}; diff --git a/mobile/services/queries/incident-reports.query.ts b/mobile/services/queries/incident-reports.query.ts new file mode 100644 index 000000000..160b5b0fc --- /dev/null +++ b/mobile/services/queries/incident-reports.query.ts @@ -0,0 +1,61 @@ +import { skipToken, useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { + getMyIncidentReports, + IncidentReportAPIResponse, +} from "../api/incident-report/incident-report.api"; +import { + ElectionRoundsAllFormsAPIResponse, + FormAPIModel, + isIncidentReportingForm, +} from "../definitions.api"; +import { AttachmentsKeys } from "./attachments.query"; +import { useElectionRoundAllForms } from "./forms.query"; + +export const IncidentReportKeys = { + all: ["incident-reports"] as const, + byElectionRound: (electionRoundId: string | undefined) => [ + ...IncidentReportKeys.all, + "electionRoundId", + electionRoundId, + ], + add: () => [...IncidentReportKeys.all, "add"] as const, + addNote: () => [...IncidentReportKeys.all, "addNote"] as const, + // the report and its notes run one after the other (also when resumed after being offline): + // a note can only be added to a report that exists on the server + mutationScope: (incidentReportId: string) => `incident-report:${incidentReportId}`, + addAttachment: () => [...AttachmentsKeys.all, ...IncidentReportKeys.all, "addAttachment"], + addAttachmentComplete: () => [...IncidentReportKeys.all, "addAttachmentComplete"], + addAttachmentAbort: () => [...IncidentReportKeys.all, "addAttachmentAbort"], +}; + +export const useIncidentReports = ( + electionRoundId?: string, + select?: (data: IncidentReportAPIResponse[]) => TResult, +) => { + return useQuery({ + queryKey: IncidentReportKeys.byElectionRound(electionRoundId), + queryFn: electionRoundId ? () => getMyIncidentReports(electionRoundId) : skipToken, + select, + }); +}; + +export const useIncidentReportById = ( + electionRoundId: string | undefined, + incidentReportId: string, +) => { + return useIncidentReports( + electionRoundId, + useCallback( + (data: IncidentReportAPIResponse[]) => + data.find((incidentReport) => incidentReport.id === incidentReportId), + [incidentReportId], + ), + ); +}; + +const selectIncidentReportingForms = (data: ElectionRoundsAllFormsAPIResponse): FormAPIModel[] => + data.forms.filter(isIncidentReportingForm); + +export const useIncidentReportingForms = (electionRoundId: string | undefined) => + useElectionRoundAllForms(electionRoundId, selectIncidentReportingForms); From d3ceceb8fa19ace0f5dbc0d93312c3d7a915c88c Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Fri, 6 Nov 2026 17:20:41 +0200 Subject: [PATCH 16/21] Mobile: keep cached data when a refetch fails while offline Only queries whose last fetch succeeded were persisted, so a refetch that failed while offline (status "error", data still there) dropped that data from the persisted cache: starting the app offline a second time found no election rounds or visits and showed the generic error screen. Also persist queries in error that still have data. --- .../persist-query/PersistQueryContext.provider.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index c8552ff52..fe5d5cbbb 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -242,7 +242,12 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { // if (query.meta?.dontPersist) return false; - return defaultShouldDehydrateQuery(query); + // a refetch that failed while offline (status "error") still has the data from before: keep it, + // otherwise starting the app offline a second time finds nothing (no election rounds, visits, ...) + return ( + defaultShouldDehydrateQuery(query) || + (query.state.status === "error" && query.state.data !== undefined) + ); }, shouldDehydrateMutation: (mutation) => { if (mutation.options.scope?.id === MUTATION_SCOPE_DO_NOT_HYDRATE) { From 00a8deedf4b4f33bb32ad37574d735876dce2d25 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Fri, 6 Nov 2026 17:20:50 +0200 Subject: [PATCH 17/21] Mobile: save incident reports while they are filled in and send them in one tap An incident report is now saved as it is filled in, so the organization gets whatever the observer managed to send even if they can not finish: - the draft is created once the location is chosen and saved again with all the answers on every next / previous question - notes are sent when saved, edited or deleted, and media files are uploaded (and can be deleted) as soon as they are picked - "Send report" is available on every question and sends the report as it is, completed, in one tap (no review step) Drafts show "In progress" on the report list and can be continued; completed reports show "Done", open read-only and show when they were sent. Sending never waits for the server: the saves share the report's mutation scope, are queued while offline (also across app restarts) and requests that get no response are retried until they go through. The question form is typed instead of useForm (the answer mapper now also accepts the Date of date and time questions), and the controls are labelled "Previous question" / "Next question" in all languages. --- .../(drawer)/(tabs)/(quick-report)/index.tsx | 22 +- .../app/(observer)/(app)/incident-report.tsx | 811 ++++++++++-------- mobile/assets/locales/az/translations_AZ.json | 2 + mobile/assets/locales/bg/translations_BG.json | 2 + mobile/assets/locales/de/translations_DE.json | 2 + mobile/assets/locales/en/translations.json | 2 + mobile/assets/locales/es/translations_ES.json | 2 + mobile/assets/locales/hy/translations_HY.json | 2 + mobile/assets/locales/ka/translations_KA.json | 2 + mobile/assets/locales/pl/translations_PL.json | 2 + .../locales/pt-BR/translations_PT-BR.json | 2 + mobile/assets/locales/ro/translations.json | 2 + mobile/assets/locales/ru/translations_RU.json | 2 + mobile/assets/locales/sr/translations_SR.json | 2 + mobile/components/IncidentReportCard.tsx | 34 +- .../components/ReviewIncidentReportSheet.tsx | 165 ---- .../PersistQueryContext.provider.tsx | 8 + mobile/services/api.ts | 4 + .../incident-report/incident-report.api.ts | 59 ++ mobile/services/form.parser.ts | 8 +- .../add-incident-report.mutation.ts | 51 ++ .../queries/incident-reports.query.ts | 25 + 22 files changed, 704 insertions(+), 507 deletions(-) delete mode 100644 mobile/components/ReviewIncidentReportSheet.tsx diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx index 45f4b1cdd..960ccd1da 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx @@ -31,6 +31,7 @@ import { import { useNetInfoContext } from "../../../../../../contexts/net-info-banner/NetInfoContext"; import { getFormDisplayName, + getFormLanguage, getIncidentReportLocationLabel, } from "../../../../../../helpers/incidentReport"; @@ -204,6 +205,23 @@ const QuickReportContent = ({ const { data: incidentReports, refetch: refetchIncidentReports } = useIncidentReports(electionRoundId); + // a sent report is locked and only viewed, a draft is continued in the language it was started in + const openListedIncidentReport = async (report: IncidentReportAPIResponse) => { + if (report.isCompleted) { + router.push(`/incident-report-details/${report.id}`); + return; + } + const form = incidentReportingForms?.find((f) => f.id === report.formId); + const preferredLanguage = await getFormLanguagePreference({ formId: report.formId }); + const language = + preferredLanguage && form?.languages.includes(preferredLanguage) + ? preferredLanguage + : getFormLanguage(form); + router.push( + `/incident-report?formId=${report.formId}&incidentReportId=${report.id}&language=${language}`, + ); + }; + const reports: ReportListItem[] = useMemo( () => [ @@ -290,7 +308,9 @@ const QuickReportContent = ({ location={getIncidentReportLocationLabel(item.report, visits)} numberOfAnswers={item.report.answers.length} numberOfAttachments={item.report.attachments.length} - onPress={() => router.push(`/incident-report-details/${item.report.id}`)} + isCompleted={item.report.isCompleted} + timestamp={item.report.timestamp} + onPress={() => openListedIncidentReport(item.report)} /> ) } diff --git a/mobile/app/(observer)/(app)/incident-report.tsx b/mobile/app/(observer)/(app)/incident-report.tsx index 74740a361..bb4b6ee4f 100644 --- a/mobile/app/(observer)/(app)/incident-report.tsx +++ b/mobile/app/(observer)/(app)/incident-report.tsx @@ -16,6 +16,7 @@ import { MUTATION_SCOPE_DO_NOT_HYDRATE, } from "../../../common/constants"; import AddAttachment from "../../../components/AddAttachment"; +import Button from "../../../components/Button"; import Card from "../../../components/Card"; import FormElement from "../../../components/FormInputs/FormElement"; import FormInput from "../../../components/FormInputs/FormInput"; @@ -27,7 +28,6 @@ import MediaLoading from "../../../components/MediaLoading"; import NoteForm from "../../../components/NoteForm"; import OptionsSheet from "../../../components/OptionsSheet"; import QuestionForm from "../../../components/QuestionForm"; -import ReviewIncidentReportSheet from "../../../components/ReviewIncidentReportSheet"; import { Screen } from "../../../components/Screen"; import { ScreenScrollView } from "../../../components/ScreenScrollView"; import Select from "../../../components/Select"; @@ -36,13 +36,11 @@ import WarningDialog from "../../../components/WarningDialog"; import WizzardControls from "../../../components/WizzardControls"; import { useNetInfoContext } from "../../../contexts/net-info-banner/NetInfoContext"; import { useUserData } from "../../../contexts/user/UserContext.provider"; +import { getFormDisplayName, getFormLanguage } from "../../../helpers/incidentReport"; import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { AttachmentData } from "../../../services/api/add-attachment.api"; +import { useBottomInset } from "../../../hooks/useBottomInset"; import { AttachmentMimeType } from "../../../services/api/get-attachments.api"; -import { - IncidentReportAttachmentAPIResponse, - IncidentReportLocationType, -} from "../../../services/api/incident-report/incident-report.api"; +import { IncidentReportLocationType } from "../../../services/api/incident-report/incident-report.api"; import { mapFormSubmissionDataToAPIFormSubmissionAnswer, removeAnswersForHiddenQuestions, @@ -64,16 +62,23 @@ import { import { useAddIncidentReport, useAddIncidentReportNote, + useDeleteIncidentReportAttachment, + useDeleteIncidentReportNote, } from "../../../services/mutations/incident-report/add-incident-report.mutation"; import { useFormById } from "../../../services/queries/forms.query"; import { - getFormDisplayName, - getFormLanguage, - getIncidentReportLocationLabel, -} from "../../../helpers/incidentReport"; -import { IncidentReportKeys } from "../../../services/queries/incident-reports.query"; + IncidentReportKeys, + useIncidentReportAttachments, + useIncidentReportById, +} from "../../../services/queries/incident-reports.query"; + +// without an id (from a server that does not send it) a note can only be shown, not edited or deleted +type ReportNote = { id?: string; text: string }; +// without an id (when it could not be loaded with its id) a file can only be shown, not deleted +type ReportAttachment = { id?: string; fileName: string; mimeType: string; uri: string }; -type LocalNote = { id: string; text: string }; +// the active question's value, keyed by question id (the shape setFormDefaultValues gives) +type QuestionFormValues = NonNullable>; type LocationFormType = { // a visited polling station id, or one of the OtherPollingStation / NotRelatedToAPollingStation location types @@ -103,14 +108,43 @@ const isLocationType = (location: string) => location === IncidentReportLocationType.OtherPollingStation || location === IncidentReportLocationType.NotRelatedToAPollingStation; +const groupByQuestion = ( + items: T[], + map: (item: T) => R, +): Record => + items.reduce( + (acc, item) => { + acc[item.questionId] = [...(acc[item.questionId] || []), map(item)]; + return acc; + }, + {} as Record, + ); + +/* + An incident report is saved while it is filled in, so the organization has whatever the observer + managed to send even if they can not finish it: + - the draft (isCompleted: false) is created once the location is chosen, and saved again with all + the answers on every "Next step" / "Back" (queued while offline, sent in order later) + - notes are sent when they are saved, edited or deleted (queued while offline, after the report) + - media files are uploaded as soon as they are picked (that needs a connection) + - "Send report" on the review saves it with isCompleted: true, which locks it (the server refuses + further changes) + A draft can be continued later from the report list (incidentReportId param). +*/ const IncidentReport = () => { - const { formId, language: languageParam } = useLocalSearchParams<{ + const params = useLocalSearchParams<{ formId: string; language?: string; + // set when continuing a draft + incidentReportId?: string; }>(); + const { formId, language: languageParam } = params; + const isResuming = !!params.incidentReportId; + const { t } = useTranslation([ "incident_report", "report_new_issue", + "report_details", "polling_station_form_wizard", ]); const queryClient = useQueryClient(); @@ -119,39 +153,55 @@ const IncidentReport = () => { const { visits, activeElectionRound } = useUserData(); const { isOnline } = useNetInfoContext(); const { uploadCameraOrMedia } = useCamera(); + const bottomInset = useBottomInset(); + + const [incidentReportId] = useState(params.incidentReportId ?? Crypto.randomUUID()); const { data: currentForm, isLoading: isLoadingForm } = useFormById( activeElectionRound?.id, formId, ); + const { data: existingReport, isLoading: isLoadingReport } = useIncidentReportById( + activeElectionRound?.id, + incidentReportId, + ); + const { data: existingAttachments, isFetchedAfterMount: areExistingAttachmentsFresh } = + useIncidentReportAttachments( + activeElectionRound?.id, + incidentReportId, + formId, + isResuming && isOnline, + ); - // the report id is generated once, so retrying a submission upserts the same report - const [incidentReportId] = useState(Crypto.randomUUID()); - const [step, setStep] = useState<"location" | "questions">("location"); + // once the draft is saved (or queued while offline) the location is fixed: the server keeps the first one + const [isSaved, setIsSaved] = useState(isResuming); + // whether the report is known to exist on the server, which uploading media needs + const isOnServerRef = useRef(isResuming); + const [isInitialized, setIsInitialized] = useState(!isResuming); + + const [step, setStep] = useState<"location" | "questions">(isResuming ? "questions" : "location"); const [questionId, setQuestionId] = useState(); const [answers, setAnswers] = useState>({}); - const [attachments, setAttachments] = useState>({}); - // notes are kept here until the report is sent - const [notes, setNotes] = useState>({}); + const [notes, setNotes] = useState>({}); + const [attachments, setAttachments] = useState>({}); + // when set, the options sheet shows the note form (a new note, or editing the one with noteId) const [noteEditor, setNoteEditor] = useState<{ noteId?: string; text?: string } | null>(null); const [selectedNoteToDelete, setSelectedNoteToDelete] = useState(null); + const [selectedAttachmentToDelete, setSelectedAttachmentToDelete] = useState(null); + const [previewAttachment, setPreviewAttachment] = useState(null); - const [isReviewSheetOpen, setIsReviewSheetOpen] = useState(false); const [showWarningDialog, setShowWarningDialog] = useState(false); const [isOptionsSheetOpen, setIsOptionsSheetOpen] = useState(false); const [isPreparingFile, setIsPreparingFile] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(""); - const [selectedAttachmentToDelete, setSelectedAttachmentToDelete] = useState(null); - const [previewAttachment, setPreviewAttachment] = useState(null); - const { - mutate: addIncidentReport, - mutateAsync: addIncidentReportAsync, - isPending, - } = useAddIncidentReport(incidentReportId); - const { mutate: addIncidentReportNote } = useAddIncidentReportNote(incidentReportId); + const { mutate: saveReport, mutateAsync: saveReportAsync } = + useAddIncidentReport(incidentReportId); + const { mutate: saveNoteOnServer } = useAddIncidentReportNote(incidentReportId); + const { mutate: deleteNoteOnServer } = useDeleteIncidentReportNote(incidentReportId); + const { mutateAsync: deleteAttachmentOnServer } = useDeleteIncidentReportAttachment(); const { mutateAsync: initAttachment } = useUploadAttachmentIncidentReportMutation(); const { mutateAsync: completeAttachment } = useUploadAttachmentIncidentReportCompleteMutation(); const { mutateAsync: abortAttachment } = useUploadAttachmentIncidentReportAbortMutation(); @@ -164,6 +214,7 @@ const IncidentReport = () => { handleSubmit: handleLocationSubmit, watch: watchLocation, getValues: getLocationValues, + reset: resetLocationForm, formState: { errors: locationErrors, isDirty: isLocationDirty }, } = useForm({ defaultValues: { location: "", locationDescription: "" }, @@ -202,8 +253,7 @@ const IncidentReport = () => { control: questionControl, handleSubmit: handleQuestionSubmit, reset: resetQuestionForm, - formState: { isDirty: isQuestionDirty }, - } = useForm(); + } = useForm(); // one screen walks through all the questions, so load the saved answer whenever the question changes useEffect(() => { @@ -212,15 +262,92 @@ const IncidentReport = () => { } }, [questionId]); - const hasProgress = - isLocationDirty || - isQuestionDirty || - Object.keys(answers).length > 0 || - Object.values(attachments).some((a) => a.length > 0) || - Object.values(notes).some((n) => n.length > 0); + const findNextQuestion = ( + index: number, + updatedAnswers: Record, + ): ApiFormQuestion | null => { + if (!currentForm || index + 1 >= currentForm.questions.length) return null; + const nextQuestion = currentForm.questions[index + 1]; + if (shouldDisplayQuestion(nextQuestion, updatedAnswers)) return nextQuestion; + return findNextQuestion(index + 1, updatedAnswers); + }; + + const findPreviousQuestion = ( + index: number, + updatedAnswers: Record, + ): ApiFormQuestion | null => { + if (!currentForm || index - 1 < 0) return null; + const prevQuestion = currentForm.questions[index - 1]; + if (shouldDisplayQuestion(prevQuestion, updatedAnswers)) return prevQuestion; + return findPreviousQuestion(index - 1, updatedAnswers); + }; + + // continuing a draft: fill in what was already sent + useEffect(() => { + if (isInitialized || !currentForm || !existingReport) return; + + if (existingReport.isCompleted) { + // a sent report is locked, it can only be viewed + router.replace(`/incident-report-details/${incidentReportId}`); + return; + } + + resetLocationForm({ + location: + existingReport.locationType === IncidentReportLocationType.PollingStation + ? existingReport.pollingStationId || "" + : existingReport.locationType, + locationDescription: existingReport.locationDescription || "", + }); + const existingAnswers = Object.fromEntries( + existingReport.answers.map((answer) => [answer.questionId, answer]), + ); + setAnswers(existingAnswers); + setNotes(groupByQuestion(existingReport.notes, (note) => ({ id: note.id, text: note.text }))); + setAttachments( + groupByQuestion(existingReport.attachments, (attachment) => ({ + fileName: attachment.fileName, + mimeType: attachment.mimeType, + uri: attachment.presignedUrl, + })), + ); + setQuestionId(findNextQuestion(-1, existingAnswers)?.id); + setIsInitialized(true); + }, [currentForm, existingReport, isInitialized]); + + // the attachments with their ids (so they can be deleted) replace the ones from the report list + const appliedExistingAttachmentsRef = useRef(false); + useEffect(() => { + // a cached list can be older than the report (e.g. a file uploaded since), so only a fresh one is used + if ( + !isInitialized || + !existingAttachments || + !areExistingAttachmentsFresh || + appliedExistingAttachmentsRef.current + ) { + return; + } + appliedExistingAttachmentsRef.current = true; + setAttachments((current) => { + const fromServer = groupByQuestion(existingAttachments, (attachment): ReportAttachment => ({ + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + uri: attachment.presignedUrl, + })); + // keep the ones uploaded in the meantime + Object.entries(current).forEach(([id, questionAttachments]) => { + const known = new Set((fromServer[id] || []).map((a) => a.id)); + const added = questionAttachments.filter((a) => a.id && !known.has(a.id)); + if (added.length) fromServer[id] = [...(fromServer[id] || []), ...added]; + }); + return fromServer; + }); + }, [isInitialized, existingAttachments, areExistingAttachmentsFresh]); const handleGoBack = () => { - if (hasProgress) { + // a saved draft can be continued from the report list, only an unsaved location is lost + if (!isSaved && isLocationDirty) { Keyboard.dismiss(); setShowWarningDialog(true); } else { @@ -234,7 +361,7 @@ const IncidentReport = () => { return true; }); return () => backHandler.remove(); - }, [hasProgress]); + }, [isSaved, isLocationDirty]); const getLocation = () => { const { location, locationDescription } = getLocationValues(); @@ -246,47 +373,75 @@ const IncidentReport = () => { : { locationType: IncidentReportLocationType.PollingStation, pollingStationId: location }; }; - const findNextQuestion = ( - index: number, - updatedAnswers: Record, - ): ApiFormQuestion | null => { - if (!currentForm || index + 1 >= currentForm.questions.length) return null; - const nextQuestion = currentForm.questions[index + 1]; - if (shouldDisplayQuestion(nextQuestion, updatedAnswers)) return nextQuestion; - return findNextQuestion(index + 1, updatedAnswers); + const buildReportPayload = ( + reportAnswers: Record, + isCompleted: boolean, + ) => { + const now = new Date().toISOString(); + return { + id: incidentReportId, + electionRoundId: activeElectionRound!.id, + formId: currentForm!.id, + ...getLocation(), + answers: Object.values(reportAnswers).filter(Boolean) as ApiFormAnswer[], + isCompleted, + lastUpdatedAt: now, + // only for showing the report in the list until it is reloaded from the server + attachments: Object.entries(attachments).flatMap(([id, questionAttachments]) => + questionAttachments.map((attachment) => ({ + submissionId: incidentReportId, + questionId: id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + presignedUrl: attachment.uri, + urlValidityInSeconds: 0, + })), + ), + notes: Object.entries(notes).flatMap(([id, questionNotes]) => + questionNotes.map((note) => ({ + id: note.id, + submissionId: incidentReportId, + questionId: id, + text: note.text, + timeSubmitted: now, + })), + ), + }; }; - const findPreviousQuestion = (index: number): ApiFormQuestion | null => { - if (!currentForm || index - 1 < 0) return null; - const prevQuestion = currentForm.questions[index - 1]; - if (shouldDisplayQuestion(prevQuestion, answers)) return prevQuestion; - return findPreviousQuestion(index - 1); + // saves the draft; queued (and sent in order with the notes) while offline + const saveDraft = (reportAnswers: Record) => { + if (!activeElectionRound || !currentForm) return; + saveReport(buildReportPayload(reportAnswers, false), { + onSuccess: () => { + isOnServerRef.current = true; + }, + }); + setIsSaved(true); + }; + + // media files reference the report, so it has to exist on the server before uploading one + const ensureReportOnServer = async () => { + if (isOnServerRef.current) return; + await saveReportAsync(buildReportPayload(answers, false)); + isOnServerRef.current = true; }; const onSubmitLocation = () => { + saveDraft(answers); const firstQuestion = findNextQuestion(-1, answers); if (!firstQuestion) { - // a form without questions: nothing to fill in, go straight to the review - Keyboard.dismiss(); - setIsReviewSheetOpen(true); + // a form without questions: nothing to fill in, the report is sent right away + sendReport(answers); return; } setQuestionId((current) => current ?? firstQuestion.id); setStep("questions"); }; - const goToPrevious = () => { - if (!activeQuestion) return; - const prevQuestion = findPreviousQuestion(activeQuestion.indexInAllQuestions); - if (prevQuestion) { - setQuestionId(prevQuestion.id); - } else { - setStep("location"); - } - }; - - const onSubmitAnswer = (formValues: any) => { - if (!activeQuestion) return; + // keeps the active question's answer and saves the draft when anything changed + const commitAnswer = (formValues: QuestionFormValues) => { + if (!activeQuestion) return answers; const id = activeQuestion.question.id; const updatedAnswer = withAnswerTimestamps( @@ -303,50 +458,89 @@ const IncidentReport = () => { ...answers, [id]: updatedAnswer, }) as Record; - setAnswers(updatedAnswers); + if (JSON.stringify(updatedAnswers) !== JSON.stringify(answers)) { + setAnswers(updatedAnswers); + saveDraft(updatedAnswers); + } + return updatedAnswers; + }; + + const onSubmitAnswer = (formValues: QuestionFormValues) => { + if (!activeQuestion) return; + const updatedAnswers = commitAnswer(formValues); const nextQuestion = findNextQuestion(activeQuestion.indexInAllQuestions, updatedAnswers); if (nextQuestion) { setQuestionId(nextQuestion.id); - } else { - Keyboard.dismiss(); - setIsReviewSheetOpen(true); } }; - const uploadAttachment = async ( - attachment: AttachmentData, - questionId: string, - uploadedPartsNo: number, - totalParts: number, - ) => { + // the report can be sent at any point, in one tap (no review step: an observer in danger must be + // able to send what they have right away), with whatever was filled in so far + const onSendNow = (formValues: QuestionFormValues) => { + sendReport(commitAnswer(formValues)); + }; + + const onPreviousAnswer = (formValues: QuestionFormValues) => { + if (!activeQuestion) return; + const updatedAnswers = commitAnswer(formValues); + const prevQuestion = findPreviousQuestion(activeQuestion.indexInAllQuestions, updatedAnswers); + if (prevQuestion) { + setQuestionId(prevQuestion.id); + } else if (!isSaved) { + setStep("location"); + } + }; + + // never waits for the server: the save is queued (with the earlier ones) and sent as soon as there + // is a connection, retried while there is none, and kept if the app is closed while offline + const sendReport = (reportAnswers: Record) => { if (!activeElectionRound || !currentForm) return; - const { uploadId, uploadUrls } = await initAttachment({ - id: attachment.id, - electionRoundId: activeElectionRound.id, - incidentReportId, - formId: currentForm.id, - questionId, - fileName: attachment.fileMetadata.name, - contentType: attachment.fileMetadata.type, - numberOfUploadParts: Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE), - }); + Keyboard.dismiss(); + saveReport(buildReportPayload(reportAnswers, true)); + router.back(); + }; + + const uploadNewAttachment = async (fileMetadata: FileMetadata) => { + if (!activeElectionRound || !currentForm || !questionId) return; + const attachmentQuestionId = questionId; + const attachmentId = Crypto.randomUUID(); + let uploadId: string | undefined; + + cancelRef.current = false; + setIsUploading(true); + setUploadProgress(t("upload.starting", { ns: "report_new_issue" })); try { + await ensureReportOnServer(); + + const numberOfUploadParts = Math.ceil(fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE); + const upload = await initAttachment({ + id: attachmentId, + electionRoundId: activeElectionRound.id, + incidentReportId, + formId: currentForm.id, + questionId: attachmentQuestionId, + fileName: fileMetadata.name, + contentType: fileMetadata.type, + numberOfUploadParts, + }); + uploadId = upload.uploadId; + let etags: Record = {}; - for (const [index, url] of Object.values(uploadUrls).entries()) { + for (const [index, url] of Object.values(upload.uploadUrls).entries()) { if (cancelRef.current) { throw new Error("Upload aborted"); } const data = await uploadS3Chunk({ url, - filePath: attachment.fileMetadata.uri, + filePath: fileMetadata.uri, partIndex: index, partSize: MULTIPART_FILE_UPLOAD_SIZE, }); setUploadProgress( - `${t("upload.progress", { ns: "report_new_issue" })} ${Math.ceil(((uploadedPartsNo + index) / totalParts) * 100)}%`, + `${t("upload.progress", { ns: "report_new_issue" })} ${Math.ceil(((index + 1) / numberOfUploadParts) * 100)}%`, ); etags = { ...etags, [index + 1]: data.ETag }; } @@ -355,138 +549,58 @@ const IncidentReport = () => { uploadId, etags, electionRoundId: activeElectionRound.id, - id: attachment.id, + id: attachmentId, incidentReportId, }); setAttachments((current) => ({ ...current, - [questionId]: current[questionId].map((a) => - a.id === attachment.id ? { ...a, uploaded: true } : a, - ), + [attachmentQuestionId]: [ + ...(current[attachmentQuestionId] || []), + { + id: attachmentId, + fileName: fileMetadata.name, + mimeType: fileMetadata.type, + uri: fileMetadata.uri, + }, + ], })); - } catch (err) { - Sentry.captureException(err, { data: { incidentReportId, questionId } }); - setUploadProgress(t("upload.aborted", { ns: "report_new_issue" })); - await abortAttachment({ - id: attachment.id, - uploadId, - electionRoundId: activeElectionRound.id, - incidentReportId, + setIsOptionsSheetOpen(false); + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.attachments(activeElectionRound.id, incidentReportId), }); - } - }; - - const uploadAttachments = async () => { - // only the attachments of questions that are still displayed - const pending = displayedQuestions.flatMap((question) => - (attachments[question.id] || []) - .filter((attachment) => !attachment.uploaded) - .map((attachment) => ({ attachment, questionId: question.id })), - ); - if (!pending.length) return; - - setIsUploading(true); - cancelRef.current = false; - setUploadProgress(t("upload.starting", { ns: "report_new_issue" })); - - const totalParts = pending.reduce( - (acc, { attachment }) => - acc + Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE), - 0, - ); - let uploadedPartsNo = 0; - - for (const { attachment, questionId } of pending) { - if (cancelRef.current) break; - try { - await uploadAttachment(attachment, questionId, uploadedPartsNo, totalParts); - } catch (err) { - Sentry.captureException(err); + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(activeElectionRound.id), + }); + } catch (err) { + Sentry.captureException(err, { data: { incidentReportId } }); + if (uploadId) { + abortAttachment({ + id: attachmentId, + uploadId, + electionRoundId: activeElectionRound.id, + incidentReportId, + }).catch(() => undefined); } - uploadedPartsNo += Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE); + if (!cancelRef.current) { + Toast.show({ type: "error", text2: t("media.error", { ns: "report_new_issue" }) }); + } + } finally { + setIsUploading(false); + setUploadProgress(""); } + }; - setUploadProgress(t("upload.completed", { ns: "report_new_issue" })); + const onAbortUpload = () => { + cancelRef.current = true; setIsUploading(false); - queryClient.invalidateQueries({ - queryKey: IncidentReportKeys.byElectionRound(activeElectionRound?.id), - }); + setIsOptionsSheetOpen(false); + setUploadProgress(""); + removeMutationByScopeId(queryClient, MUTATION_SCOPE_DO_NOT_HYDRATE); }; - const onSubmitReport = async () => { - if (!activeElectionRound || !currentForm) return; - - const optimisticAttachments: IncidentReportAttachmentAPIResponse[] = displayedQuestions.flatMap( - (question) => - (attachments[question.id] || []).map((attachment) => ({ - submissionId: incidentReportId, - questionId: question.id, - fileName: attachment.fileMetadata.name, - mimeType: attachment.fileMetadata.type, - presignedUrl: attachment.fileMetadata.uri, - urlValidityInSeconds: 0, - })), - ); - - const payload = { - id: incidentReportId, - electionRoundId: activeElectionRound.id, - formId: currentForm.id, - ...getLocation(), - answers: Object.values(answers).filter(Boolean) as ApiFormAnswer[], - isCompleted: true, - lastUpdatedAt: new Date().toISOString(), - attachments: optimisticAttachments, - notes: displayedQuestions.flatMap((question) => - (notes[question.id] || []).map((note) => ({ - submissionId: incidentReportId, - questionId: question.id, - text: note.text, - timeSubmitted: new Date().toISOString(), - })), - ), - }; - - // the notes share the report's mutation scope, so they are sent (also after being offline) - // only once the report was created - const sendNotes = () => { - displayedQuestions.forEach((question) => - (notes[question.id] || []).forEach((note) => - addIncidentReportNote({ - id: note.id, - electionRoundId: activeElectionRound.id, - incidentReportId, - formId: currentForm.id, - questionId: question.id, - text: note.text, - }), - ), - ); - }; - - const onError = () => { - Toast.show({ type: "error", text2: t("error") }); - }; - - if (!optimisticAttachments.length) { - // like quick reports: without attachments the report is queued while offline and sent later - addIncidentReport(payload, { - onError, - onSuccess: () => { - setIsReviewSheetOpen(false); - router.back(); - }, - }); - sendNotes(); - if (!onlineManager.isOnline()) { - setIsReviewSheetOpen(false); - router.back(); - } - return; - } - - // attachments reference the incident report, so it has to exist on the server before uploading them + const removeAttachment = async (id: string) => { + if (!activeElectionRound || !questionId) return; if (!onlineManager.isOnline()) { Toast.show({ type: "error", @@ -496,52 +610,23 @@ const IncidentReport = () => { }); return; } - + const attachmentQuestionId = questionId; try { - await addIncidentReportAsync(payload); - } catch { - onError(); - return; - } - sendNotes(); - - await uploadAttachments(); - - if (!cancelRef.current) { - setIsReviewSheetOpen(false); - router.back(); + await deleteAttachmentOnServer({ + id, + electionRoundId: activeElectionRound.id, + incidentReportId, + }); + setAttachments((current) => ({ + ...current, + [attachmentQuestionId]: (current[attachmentQuestionId] || []).filter((a) => a.id !== id), + })); + } catch (err) { + Sentry.captureException(err); + Toast.show({ type: "error", text2: t("error") }); } }; - const onAbortUpload = () => { - // the report itself is already sent, only the remaining attachments are dropped - cancelRef.current = true; - setIsUploading(false); - setUploadProgress(""); - removeMutationByScopeId(queryClient, MUTATION_SCOPE_DO_NOT_HYDRATE); - setIsReviewSheetOpen(false); - router.back(); - }; - - const addLocalAttachment = (fileMetadata: FileMetadata) => { - if (!questionId) return; - setAttachments((current) => ({ - ...current, - [questionId]: [ - ...(current[questionId] || []), - { fileMetadata, id: Crypto.randomUUID(), uploaded: false }, - ], - })); - }; - - const removeLocalAttachment = (id: string) => { - if (!questionId) return; - setAttachments((current) => ({ - ...current, - [questionId]: (current[questionId] || []).filter((attachment) => attachment.id !== id), - })); - }; - const handleCameraUpload = async (type: "library" | "cameraPhoto") => { setIsPreparingFile(true); setUploadProgress(t("upload.preparing", { ns: "report_new_issue" })); @@ -550,19 +635,19 @@ const IncidentReport = () => { `${t("upload.compressing", { ns: "report_new_issue" })} ${Math.ceil(progress * 100)}%`, ), ); + setIsPreparingFile(false); if (cameraResult) { - setIsOptionsSheetOpen(false); - addLocalAttachment(cameraResult); + await uploadNewAttachment(cameraResult); + } else { + setUploadProgress(""); } - setIsPreparingFile(false); }; const handleUploadAudio = async () => { const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false }); const file = doc?.assets?.[0]; if (file) { - setIsOptionsSheetOpen(false); - addLocalAttachment({ + await uploadNewAttachment({ name: file.name, type: file.mimeType || "audio/mpeg", uri: file.uri, @@ -571,34 +656,48 @@ const IncidentReport = () => { } }; - const saveLocalNote = (text: string) => { - if (!questionId || !noteEditor) return; - const { noteId } = noteEditor; + const saveNote = (text: string) => { + if (!activeElectionRound || !currentForm || !questionId || !noteEditor) return; + const noteQuestionId = questionId; + const id = noteEditor.noteId ?? Crypto.randomUUID(); + setNotes((current) => { - const questionNotes = current[questionId] || []; + const questionNotes = current[noteQuestionId] || []; return { ...current, - [questionId]: noteId - ? questionNotes.map((note) => (note.id === noteId ? { ...note, text } : note)) - : [...questionNotes, { id: Crypto.randomUUID(), text }], + [noteQuestionId]: noteEditor.noteId + ? questionNotes.map((note) => (note.id === id ? { ...note, text } : note)) + : [...questionNotes, { id, text }], }; }); + // shares the report's mutation scope: sent after the report, also when queued offline + saveNoteOnServer({ + id, + electionRoundId: activeElectionRound.id, + incidentReportId, + formId: currentForm.id, + questionId: noteQuestionId, + text, + }); + Keyboard.dismiss(); setNoteEditor(null); setIsOptionsSheetOpen(false); }; - const removeLocalNote = (id: string) => { - if (!questionId) return; + const removeNote = (id: string) => { + if (!activeElectionRound || !questionId) return; + const noteQuestionId = questionId; setNotes((current) => ({ ...current, - [questionId]: (current[questionId] || []).filter((note) => note.id !== id), + [noteQuestionId]: (current[noteQuestionId] || []).filter((note) => note.id !== id), })); + deleteNoteOnServer({ id, electionRoundId: activeElectionRound.id, incidentReportId }); }; - const openNoteEditor = (note?: LocalNote) => { + const openNoteEditor = (note: ReportNote) => { Keyboard.dismiss(); - setNoteEditor(note ? { noteId: note.id, text: note.text } : {}); + setNoteEditor({ noteId: note.id, text: note.text }); setIsOptionsSheetOpen(true); }; @@ -607,7 +706,7 @@ const IncidentReport = () => { setIsOptionsSheetOpen(true); }; - // adding media needs a connection (the files are uploaded when the report is sent) + // adding media needs a connection (the files are uploaded right away) const whenOnline = (action: () => void) => () => { if (isOnline) { action(); @@ -625,7 +724,7 @@ const IncidentReport = () => { return Incorrect page params; } - if (isLoadingForm) { + if (isLoadingForm || (isResuming && isLoadingReport)) { return ( @@ -633,7 +732,7 @@ const IncidentReport = () => { ); } - if (!currentForm) { + if (!currentForm || (isResuming && !existingReport)) { return (
{ leftIcon={} onLeftPress={() => router.back()} /> - {t("form_not_found")} + + {currentForm ? t("error", { ns: "report_details" }) : t("form_not_found")} + ); } + if (!isInitialized) { + return ( + + + + ); + } + const questionAttachments = (questionId && attachments[questionId]) || []; const questionNotes = (questionId && notes[questionId]) || []; + const isFirstQuestion = + !!activeQuestion && findPreviousQuestion(activeQuestion.indexInAllQuestions, answers) === null; + const isLastQuestion = + !!activeQuestion && findNextQuestion(activeQuestion.indexInAllQuestions, answers) === null; + // once saved, the location can not be changed any more + const showPrevious = !isFirstQuestion || !isSaved; return ( { ) : ( - + + {showPrevious || !isLastQuestion ? ( + + {showPrevious ? ( + + ) : ( + false + )} + {!isLastQuestion ? ( + + ) : ( + false + )} + + ) : ( + false + )} + {/* the main action: sends the report as it is, at any question */} + + ) } > @@ -768,28 +910,32 @@ const IncidentReport = () => { {t("notes.heading", { ns: "polling_station_form_wizard" })} - {questionNotes.map((note) => ( + {questionNotes.map((note, index) => ( openNoteEditor(note)} + onPress={note.id ? () => openNoteEditor(note) : undefined} > {note.text} - { - Keyboard.dismiss(); - setSelectedNoteToDelete(note.id); - }} - pressStyle={{ opacity: 0.5 }} - > - - + {note.id ? ( + { + Keyboard.dismiss(); + setSelectedNoteToDelete(note.id!); + }} + pressStyle={{ opacity: 0.5 }} + > + + + ) : ( + false + )} ))} @@ -800,29 +946,39 @@ const IncidentReport = () => { {questionAttachments.length ? ( {t("attachments.heading")} - {questionAttachments.map((attachment) => ( + {questionAttachments.map((attachment, index) => ( setPreviewAttachment(attachment)} > - - {attachment.fileMetadata.name} - - { - Keyboard.dismiss(); - setSelectedAttachmentToDelete(attachment.id); - }} - pressStyle={{ opacity: 0.5 }} + - - + {attachment.fileName} + + {attachment.id ? ( + { + Keyboard.dismiss(); + setSelectedAttachmentToDelete(attachment.id!); + }} + pressStyle={{ opacity: 0.5 }} + > + + + ) : ( + false + )} ))} @@ -840,12 +996,9 @@ const IncidentReport = () => { )} - {previewAttachment && previewAttachment.fileMetadata.type === AttachmentMimeType.IMG && ( + {previewAttachment && previewAttachment.mimeType === AttachmentMimeType.IMG && ( setPreviewAttachment(null)} /> )} @@ -857,7 +1010,7 @@ const IncidentReport = () => { actionBtnText={t("attachments.delete.actions.clear")} cancelBtnText={t("attachments.delete.actions.cancel")} action={() => { - removeLocalAttachment(selectedAttachmentToDelete); + removeAttachment(selectedAttachmentToDelete); setSelectedAttachmentToDelete(null); }} onCancel={() => setSelectedAttachmentToDelete(null)} @@ -871,42 +1024,30 @@ const IncidentReport = () => { actionBtnText={t("notes.delete.actions.delete", { ns: "polling_station_form_wizard" })} cancelBtnText={t("notes.delete.actions.cancel", { ns: "polling_station_form_wizard" })} action={() => { - removeLocalNote(selectedNoteToDelete); + removeNote(selectedNoteToDelete); setSelectedNoteToDelete(null); }} onCancel={() => setSelectedNoteToDelete(null)} /> )} - {isReviewSheetOpen && ( - setIsReviewSheetOpen(false)} - /> - )} - {isOptionsSheetOpen && ( { + if (isUploading) return; setIsOptionsSheetOpen(open); if (!open) setNoteEditor(null); }} - isLoading={isPreparingFile} + isLoading={isPreparingFile || isUploading} disableDrag={!!noteEditor} > - {isPreparingFile ? ( - + {isPreparingFile || isUploading ? ( + ) : noteEditor ? ( { : t("notes.add.heading", { ns: "polling_station_form_wizard" }) } initialText={noteEditor.text} - onSave={saveLocalNote} + onSave={saveNote} onCancel={() => setNoteEditor(null)} /> ) : ( diff --git a/mobile/assets/locales/az/translations_AZ.json b/mobile/assets/locales/az/translations_AZ.json index 45abe7d82..36834ec0f 100644 --- a/mobile/assets/locales/az/translations_AZ.json +++ b/mobile/assets/locales/az/translations_AZ.json @@ -730,6 +730,8 @@ "title": "İnsident hesabatı", "form_not_found": "Bu insident hesabatı forması artıq mövcud deyil.", "progress_bar_label": "Formanın tərəqqisi", + "previous_question": "Əvvəlki sual", + "next_question": "Növbəti sual", "error": "İnsident hesabatı göndərilərkən problem baş verdi.", "report_type_sheet": { "title": "Nə barədə məlumat vermək istəyirsiniz?", diff --git a/mobile/assets/locales/bg/translations_BG.json b/mobile/assets/locales/bg/translations_BG.json index 0f247f93b..03e09cd15 100644 --- a/mobile/assets/locales/bg/translations_BG.json +++ b/mobile/assets/locales/bg/translations_BG.json @@ -733,6 +733,8 @@ "title": "Доклад за инцидент", "form_not_found": "Този формуляр за доклад за инцидент вече не е наличен.", "progress_bar_label": "Прогрес на формуляра", + "previous_question": "Предишен въпрос", + "next_question": "Следващ въпрос", "error": "Възникна проблем при изпращането на доклада за инцидент.", "report_type_sheet": { "title": "Какво искате да докладвате?", diff --git a/mobile/assets/locales/de/translations_DE.json b/mobile/assets/locales/de/translations_DE.json index 4d78e022d..31f96ceac 100644 --- a/mobile/assets/locales/de/translations_DE.json +++ b/mobile/assets/locales/de/translations_DE.json @@ -733,6 +733,8 @@ "title": "Vorfallsbericht", "form_not_found": "Dieses Formular für Vorfallsberichte ist nicht mehr verfügbar.", "progress_bar_label": "Formularfortschritt", + "previous_question": "Vorherige Frage", + "next_question": "Nächste Frage", "error": "Beim Senden des Vorfallsberichts ist ein Problem aufgetreten.", "report_type_sheet": { "title": "Was möchten Sie melden?", diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index f18e6bfe6..2af2f406e 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -733,6 +733,8 @@ "title": "Incident report", "form_not_found": "This incident report form is no longer available.", "progress_bar_label": "Form progress", + "previous_question": "Previous question", + "next_question": "Next question", "error": "There was a problem while sending the incident report.", "report_type_sheet": { "title": "What do you want to report?", diff --git a/mobile/assets/locales/es/translations_ES.json b/mobile/assets/locales/es/translations_ES.json index 0fd614b13..92336beae 100644 --- a/mobile/assets/locales/es/translations_ES.json +++ b/mobile/assets/locales/es/translations_ES.json @@ -732,6 +732,8 @@ "title": "Informe de incidente", "form_not_found": "Este formulario de informe de incidente ya no está disponible.", "progress_bar_label": "Progreso del formulario", + "previous_question": "Pregunta anterior", + "next_question": "Siguiente pregunta", "error": "Hubo un problema al enviar el informe de incidente.", "report_type_sheet": { "title": "¿Qué quieres reportar?", diff --git a/mobile/assets/locales/hy/translations_HY.json b/mobile/assets/locales/hy/translations_HY.json index 1af8344e8..dfbd31737 100644 --- a/mobile/assets/locales/hy/translations_HY.json +++ b/mobile/assets/locales/hy/translations_HY.json @@ -733,6 +733,8 @@ "title": "Միջադեպի մասին հաղորդում", "form_not_found": "Միջադեպի մասին հաղորդման այս ձևաթուղթն այլևս հասանելի չէ:", "progress_bar_label": "Ձևաթղթի առաջընթացը", + "previous_question": "Նախորդ հարց", + "next_question": "Հաջորդ հարց", "error": "Միջադեպի մասին հաղորդումն ուղարկելիս սխալ է տեղի ունեցել:", "report_type_sheet": { "title": "Ի՞նչի մասին եք ցանկանում հաղորդել:", diff --git a/mobile/assets/locales/ka/translations_KA.json b/mobile/assets/locales/ka/translations_KA.json index 2eb910b5d..018ce5d31 100644 --- a/mobile/assets/locales/ka/translations_KA.json +++ b/mobile/assets/locales/ka/translations_KA.json @@ -729,6 +729,8 @@ "title": "ინციდენტის ანგარიში", "form_not_found": "ინციდენტის ანგარიშის ეს ფორმა აღარ არის ხელმისაწვდომი.", "progress_bar_label": "ფორმის პროგრესი", + "previous_question": "წინა კითხვა", + "next_question": "შემდეგი კითხვა", "error": "ინციდენტის ანგარიშის გაგზავნისას დაფიქსირდა შეცდომა.", "report_type_sheet": { "title": "რის შესახებ გსურთ შეტყობინება?", diff --git a/mobile/assets/locales/pl/translations_PL.json b/mobile/assets/locales/pl/translations_PL.json index 83e770645..b01cb1833 100644 --- a/mobile/assets/locales/pl/translations_PL.json +++ b/mobile/assets/locales/pl/translations_PL.json @@ -737,6 +737,8 @@ "title": "Raport o incydencie", "form_not_found": "Ten formularz raportu o incydencie nie jest już dostępny.", "progress_bar_label": "Postęp formularza", + "previous_question": "Poprzednie pytanie", + "next_question": "Następne pytanie", "error": "Wystąpił problem podczas wysyłania raportu o incydencie.", "report_type_sheet": { "title": "Co chcesz zgłosić?", diff --git a/mobile/assets/locales/pt-BR/translations_PT-BR.json b/mobile/assets/locales/pt-BR/translations_PT-BR.json index 35503f654..5a862b1f3 100644 --- a/mobile/assets/locales/pt-BR/translations_PT-BR.json +++ b/mobile/assets/locales/pt-BR/translations_PT-BR.json @@ -733,6 +733,8 @@ "title": "Relatório de incidente", "form_not_found": "Este formulário de relatório de incidente não está mais disponível.", "progress_bar_label": "Progresso do formulário", + "previous_question": "Pergunta anterior", + "next_question": "Próxima pergunta", "error": "Houve um problema ao enviar o relatório de incidente.", "report_type_sheet": { "title": "O que você deseja relatar?", diff --git a/mobile/assets/locales/ro/translations.json b/mobile/assets/locales/ro/translations.json index 2cc866408..4656ecdf6 100644 --- a/mobile/assets/locales/ro/translations.json +++ b/mobile/assets/locales/ro/translations.json @@ -733,6 +733,8 @@ "title": "Raport de incident", "form_not_found": "Acest formular de raportare a incidentelor nu mai este disponibil.", "progress_bar_label": "Progresul formularului", + "previous_question": "Întrebarea anterioară", + "next_question": "Întrebarea următoare", "error": "A apărut o problemă la trimiterea raportului de incident.", "report_type_sheet": { "title": "Ce vrei să raportezi?", diff --git a/mobile/assets/locales/ru/translations_RU.json b/mobile/assets/locales/ru/translations_RU.json index 6313519fd..f86e4ec75 100644 --- a/mobile/assets/locales/ru/translations_RU.json +++ b/mobile/assets/locales/ru/translations_RU.json @@ -730,6 +730,8 @@ "title": "Отчет об инциденте", "form_not_found": "Эта форма отчета об инциденте больше недоступна.", "progress_bar_label": "Прогресс формы", + "previous_question": "Предыдущий вопрос", + "next_question": "Следующий вопрос", "error": "При отправке отчета об инциденте возникла проблема.", "report_type_sheet": { "title": "О чем вы хотите сообщить?", diff --git a/mobile/assets/locales/sr/translations_SR.json b/mobile/assets/locales/sr/translations_SR.json index 0097ceb6c..08545951f 100644 --- a/mobile/assets/locales/sr/translations_SR.json +++ b/mobile/assets/locales/sr/translations_SR.json @@ -730,6 +730,8 @@ "title": "Izveštaj o incidentu", "form_not_found": "Ovaj obrazac za izveštaj o incidentu više nije dostupan.", "progress_bar_label": "Napredak obrasca", + "previous_question": "Prethodno pitanje", + "next_question": "Sledeće pitanje", "error": "Došlo je do problema prilikom slanja izveštaja o incidentu.", "report_type_sheet": { "title": "Šta želite da prijavite?", diff --git a/mobile/components/IncidentReportCard.tsx b/mobile/components/IncidentReportCard.tsx index e16b851d4..36a5a7c19 100644 --- a/mobile/components/IncidentReportCard.tsx +++ b/mobile/components/IncidentReportCard.tsx @@ -1,7 +1,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { YStack } from "tamagui"; +import { XStack, YStack } from "tamagui"; +import Badge, { Status } from "./Badge"; import Card from "./Card"; +import { Icon } from "./Icon"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; import CardFooter from "./CardFooter"; import { Typography } from "./Typography"; @@ -10,6 +13,10 @@ interface IncidentReportCardProps { location: string; numberOfAnswers: number; numberOfAttachments: number; + // a draft (not completed) can still be continued + isCompleted: boolean; + // when it was sent (or, for a draft, last saved) + timestamp?: string; onPress?: () => void; } @@ -18,14 +25,35 @@ const IncidentReportCard = ({ location, numberOfAnswers, numberOfAttachments, + isCompleted, + timestamp, onPress, }: IncidentReportCardProps): React.ReactElement => { - const { t } = useTranslation(["incident_report", "quick_report"]); + const { t } = useTranslation(["incident_report", "quick_report", "common"]); + const { formatDate, formatTime } = useDateTimeFormat(); + const date = timestamp ? new Date(timestamp) : undefined; return ( - {formName} + + + {formName} + + + {isCompleted + ? t("status.completed", { ns: "common" }) + : t("status.in_progress", { ns: "common" })} + + + {date ? ( + + + {`${formatDate(date)} ${formatTime(date)}`} + + ) : ( + false + )} {location ? {location} : false} {t("list.answers", { count: numberOfAnswers })} | undefined; - attachments: Record; - notes: Record; - isSubmitting: boolean; - isUploading: boolean; - uploadProgress: string; - onSubmit: () => void; - onAbortUpload: () => void; - onClose: () => void; -} - -const ReviewIncidentReportSheet = ({ - currentForm, - language, - locationLabel, - answers, - attachments, - notes, - isSubmitting, - isUploading, - uploadProgress, - onSubmit, - onAbortUpload, - onClose, -}: ReviewIncidentReportSheetProps) => { - const { t } = useTranslation(["incident_report", "polling_station_form_wizard"]); - const dateTimeFormat = useDateTimeFormat(); - const insets = useSafeAreaInsets(); - - const mappedAnswers = useMemo( - () => mapAnswersForDisplay(answers, currentForm.questions, language), - [answers, currentForm.questions, language], - ); - - // only the questions with an answer, notes or media (with skip logic, some were never shown) - const answeredQuestions = useMemo( - () => - currentForm.questions.filter( - (question) => - mappedAnswers[question.id] || - notes[question.id]?.length || - attachments[question.id]?.length, - ), - [currentForm.questions, mappedAnswers, notes, attachments], - ); - - const isBusy = isSubmitting || isUploading; - - return ( - { - if (!open) { - onClose(); - } - }} - > - - - {isUploading ? ( - - attachment.uploaded).length - } - onAbortUpload={onAbortUpload} - confirmAbort - /> - - ) : ( - <> - - - {isSubmitting ? ( - - - - {t("review.sending_report")} - - - ) : ( - - {t("review.heading")} - - - {t("location.label")} - - {locationLabel} - - - - - {answeredQuestions.map((question) => ( - - {question.text[language]} - {mappedAnswers[question.id] ? ( - - {getAnswerDisplay(mappedAnswers[question.id], true, dateTimeFormat)} - - ) : ( - false - )} - {notes[question.id]?.map((note) => ( - - {`${t("notes.heading", { ns: "polling_station_form_wizard" })}: ${note.text}`} - - ))} - {attachments[question.id]?.length ? ( - - {t("attachments.heading")}: {attachments[question.id].length} - - ) : ( - false - )} - - - ))} - - )} - - - - - - - )} - - - ); -}; - -export default ReviewIncidentReportSheet; diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index fe5d5cbbb..93a4ec65c 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -29,6 +29,8 @@ import { upsertIncidentReport, upsertIncidentReportNote, UpsertIncidentReportNoteAPIPayload, + deleteIncidentReportNote, + DeleteIncidentReportNoteAPIPayload, } from "../../services/api/incident-report/incident-report.api"; import { AddIncidentReportMutationPayload } from "../../services/mutations/incident-report/add-incident-report.mutation"; @@ -167,6 +169,12 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { }, }); + queryClient.setMutationDefaults(IncidentReportKeys.deleteNote(), { + mutationFn: (payload: DeleteIncidentReportNoteAPIPayload) => { + return deleteIncidentReportNote(payload); + }, + }); + const runPendingMutations = async () => { console.log( "PersistQueryClientProvider onSuccess - Successfully get data from AsyncStorage and put in MutationCache", diff --git a/mobile/services/api.ts b/mobile/services/api.ts index 32a1ba850..5dab854b7 100644 --- a/mobile/services/api.ts +++ b/mobile/services/api.ts @@ -170,6 +170,10 @@ const normalizeUrl = (url?: string) => * connectivity) are expected on observers' phones, and the queries retry them: reporting each one only buries real * errors. Sentry's beforeSend drops them wherever they're captured. */ +// the request got no response: no connectivity, an unreachable server or a timeout +export const isNetworkError = (error: unknown) => + axios.isAxiosError(error) && !error.response && !axios.isCancel(error); + export const isExpectedApiError = (error: unknown) => { if (axios.isAxiosError(error)) { const status = error.response?.status; diff --git a/mobile/services/api/incident-report/incident-report.api.ts b/mobile/services/api/incident-report/incident-report.api.ts index 4ab418d75..0ca7126ef 100644 --- a/mobile/services/api/incident-report/incident-report.api.ts +++ b/mobile/services/api/incident-report/incident-report.api.ts @@ -20,6 +20,8 @@ export type IncidentReportAttachmentAPIResponse = { }; export type IncidentReportNoteAPIResponse = { + // missing from servers that predate it + id?: string; submissionId: string; questionId: string; text: string; @@ -172,3 +174,60 @@ export const upsertIncidentReportNote = ({ payload, ).then((res) => res.data); }; + +export type DeleteIncidentReportNoteAPIPayload = { + id: string; + electionRoundId: string; + incidentReportId: string; +}; + +export const deleteIncidentReportNote = ({ + id, + electionRoundId, + incidentReportId, +}: DeleteIncidentReportNoteAPIPayload): Promise => { + return API.delete( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/notes/${id}`, + ).then((res) => res.data); +}; + +/** ======================================================================== + ================= GET / DELETE incident report attachments ============ + ======================================================================== + @description Unlike the attachments in incident-reports:my, these have their ids (needed to delete them) +*/ +export type IncidentReportAttachmentWithIdAPIResponse = { + id: string; + incidentReportId: string; + formId: string; + questionId: string; + fileName: string; + mimeType: string; + presignedUrl: string; + urlValidityInSeconds: number; +}; + +export const getIncidentReportAttachments = ( + electionRoundId: string, + incidentReportId: string, + formId: string, +): Promise => { + return API.get<{ attachments: IncidentReportAttachmentWithIdAPIResponse[] }>( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments`, + { params: { formId } }, + ).then((res) => res.data.attachments); +}; + +export const deleteIncidentReportAttachment = ({ + id, + electionRoundId, + incidentReportId, +}: { + id: string; + electionRoundId: string; + incidentReportId: string; +}): Promise => { + return API.delete( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments/${id}`, + ).then((res) => res.data); +}; diff --git a/mobile/services/form.parser.ts b/mobile/services/form.parser.ts index fce124c2c..a31add53f 100644 --- a/mobile/services/form.parser.ts +++ b/mobile/services/form.parser.ts @@ -243,6 +243,8 @@ export const mapFormSubmissionDataToAPIFormSubmissionAnswer = ( answer: | string | number + // date, time and date-time questions + | Date | Record | Record, ): ApiFormAnswer | undefined => { @@ -271,19 +273,19 @@ export const mapFormSubmissionDataToAPIFormSubmissionAnswer = ( return { $answerType: "dateAnswer", questionId, - date: toDayIso(new Date(answer as string)), + date: toDayIso(new Date(answer as string | Date)), } as ApiFormAnswer; case "timeAnswer": return { $answerType: "timeAnswer", questionId, - time: toTimeOfDay(new Date(answer as string)), + time: toTimeOfDay(new Date(answer as string | Date)), } as ApiFormAnswer; case "dateTimeAnswer": return { $answerType: "dateTimeAnswer", questionId, - dateTime: new Date(answer as string).toISOString(), + dateTime: new Date(answer as string | Date).toISOString(), } as ApiFormAnswer; case "singleSelectAnswer": return { diff --git a/mobile/services/mutations/incident-report/add-incident-report.mutation.ts b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts index 6a8f15ef5..084a6e44d 100644 --- a/mobile/services/mutations/incident-report/add-incident-report.mutation.ts +++ b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts @@ -5,8 +5,19 @@ import { IncidentReportNoteAPIResponse, UpsertIncidentReportAPIPayload, UpsertIncidentReportNoteAPIPayload, + DeleteIncidentReportNoteAPIPayload, + deleteIncidentReportAttachment, } from "../../api/incident-report/incident-report.api"; import { IncidentReportKeys } from "../../queries/incident-reports.query"; +import { isNetworkError } from "../../api"; + +/* + Incident report saves (the report and its notes) must not be lost when the connection drops: a request + that got no response is retried (with a growing delay, paused while offline) until it goes through. + Errors the server answered (e.g. a locked report) are not retried. +*/ +const retryWhileNoConnection = (_failureCount: number, error: unknown) => isNetworkError(error); +const retryDelay = (attempt: number) => Math.min(1000 * 2 ** attempt, 30 * 1000); // attachments and notes are only used for the optimistic update, they are sent separately export type AddIncidentReportMutationPayload = UpsertIncidentReportAPIPayload & { @@ -22,6 +33,8 @@ export const useAddIncidentReport = (incidentReportId: string) => { return useMutation({ mutationKey: IncidentReportKeys.add(), scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + retry: retryWhileNoConnection, + retryDelay, onMutate: async ({ attachments, notes, @@ -71,6 +84,8 @@ export const useAddIncidentReportNote = (incidentReportId: string) => { return useMutation({ mutationKey: IncidentReportKeys.addNote(), scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + retry: retryWhileNoConnection, + retryDelay, onSettled: (_data, _err, variables: UpsertIncidentReportNoteAPIPayload) => { return queryClient.invalidateQueries({ queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), @@ -78,3 +93,39 @@ export const useAddIncidentReportNote = (incidentReportId: string) => { }, }); }; + +export const useDeleteIncidentReportNote = (incidentReportId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: IncidentReportKeys.deleteNote(), + scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + retry: retryWhileNoConnection, + retryDelay, + onSettled: (_data, _err, variables: DeleteIncidentReportNoteAPIPayload) => { + return queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + }, + }); +}; + +// online only: deleting a file needs the server (adding one does too) +export const useDeleteIncidentReportAttachment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteIncidentReportAttachment, + onSettled: (_data, _err, variables) => { + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.attachments( + variables.electionRoundId, + variables.incidentReportId, + ), + }); + }, + }); +}; diff --git a/mobile/services/queries/incident-reports.query.ts b/mobile/services/queries/incident-reports.query.ts index 160b5b0fc..3038f07d2 100644 --- a/mobile/services/queries/incident-reports.query.ts +++ b/mobile/services/queries/incident-reports.query.ts @@ -1,6 +1,7 @@ import { skipToken, useQuery } from "@tanstack/react-query"; import { useCallback } from "react"; import { + getIncidentReportAttachments, getMyIncidentReports, IncidentReportAPIResponse, } from "../api/incident-report/incident-report.api"; @@ -21,6 +22,13 @@ export const IncidentReportKeys = { ], add: () => [...IncidentReportKeys.all, "add"] as const, addNote: () => [...IncidentReportKeys.all, "addNote"] as const, + deleteNote: () => [...IncidentReportKeys.all, "deleteNote"] as const, + attachments: (electionRoundId: string | undefined, incidentReportId: string | undefined) => [ + ...IncidentReportKeys.all, + "attachments", + electionRoundId, + incidentReportId, + ], // the report and its notes run one after the other (also when resumed after being offline): // a note can only be added to a report that exists on the server mutationScope: (incidentReportId: string) => `incident-report:${incidentReportId}`, @@ -59,3 +67,20 @@ const selectIncidentReportingForms = (data: ElectionRoundsAllFormsAPIResponse): export const useIncidentReportingForms = (electionRoundId: string | undefined) => useElectionRoundAllForms(electionRoundId, selectIncidentReportingForms); + +// the attachments of a report with their ids (incident-reports:my does not have them) +export const useIncidentReportAttachments = ( + electionRoundId: string | undefined, + incidentReportId: string | undefined, + formId: string | undefined, + enabled: boolean, +) => + useQuery({ + queryKey: IncidentReportKeys.attachments(electionRoundId, incidentReportId), + queryFn: + enabled && electionRoundId && incidentReportId && formId + ? () => getIncidentReportAttachments(electionRoundId, incidentReportId, formId) + : skipToken, + // continuing a draft needs the current files + refetchOnMount: "always", + }); From 503928ac806ec274d270de49cab5fc0d08c5e6e9 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Mon, 9 Nov 2026 09:36:16 +0200 Subject: [PATCH 18/21] Mobile: move "Send report" on incident reports to the header The full-width "Send report" button sat right under "Next question", so it was easy to hit by mistake, which locks the report. Sending is still a single tap with no confirmation (an observer may be in a hurry or in danger), but it is now kept away from the navigation: - a "Send" action (send icon + label) in the top right of the header, on every question; the form title wraps / shrinks next to it - the footer uses the same Previous / Next controls as the other forms; on the last question "Next" becomes "Send report" - a toast confirms the report was sent, or that it will be sent once back online Header gets an optional rightAction slot for a labelled action and WizzardControls an optional label for its back button. Also fixes the success toast background colour (whsl -> hsl), which left its white text unreadable. --- .../app/(observer)/(app)/incident-report.tsx | 82 +++++++++---------- mobile/assets/icons/send.svg | 5 ++ mobile/assets/locales/az/translations_AZ.json | 5 ++ mobile/assets/locales/bg/translations_BG.json | 5 ++ mobile/assets/locales/de/translations_DE.json | 5 ++ mobile/assets/locales/en/translations.json | 5 ++ mobile/assets/locales/es/translations_ES.json | 5 ++ mobile/assets/locales/hy/translations_HY.json | 5 ++ mobile/assets/locales/ka/translations_KA.json | 5 ++ mobile/assets/locales/pl/translations_PL.json | 5 ++ .../locales/pt-BR/translations_PT-BR.json | 5 ++ mobile/assets/locales/ro/translations.json | 5 ++ mobile/assets/locales/ru/translations_RU.json | 5 ++ mobile/assets/locales/sr/translations_SR.json | 5 ++ mobile/components/Header.tsx | 33 ++++++-- mobile/components/Icon.tsx | 2 + mobile/components/WizzardControls.tsx | 4 +- mobile/toast.config.js | 2 +- 18 files changed, 136 insertions(+), 52 deletions(-) create mode 100644 mobile/assets/icons/send.svg diff --git a/mobile/app/(observer)/(app)/incident-report.tsx b/mobile/app/(observer)/(app)/incident-report.tsx index bb4b6ee4f..5d3c40d93 100644 --- a/mobile/app/(observer)/(app)/incident-report.tsx +++ b/mobile/app/(observer)/(app)/incident-report.tsx @@ -16,7 +16,6 @@ import { MUTATION_SCOPE_DO_NOT_HYDRATE, } from "../../../common/constants"; import AddAttachment from "../../../components/AddAttachment"; -import Button from "../../../components/Button"; import Card from "../../../components/Card"; import FormElement from "../../../components/FormInputs/FormElement"; import FormInput from "../../../components/FormInputs/FormInput"; @@ -38,7 +37,6 @@ import { useNetInfoContext } from "../../../contexts/net-info-banner/NetInfoCont import { useUserData } from "../../../contexts/user/UserContext.provider"; import { getFormDisplayName, getFormLanguage } from "../../../helpers/incidentReport"; import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { useBottomInset } from "../../../hooks/useBottomInset"; import { AttachmentMimeType } from "../../../services/api/get-attachments.api"; import { IncidentReportLocationType } from "../../../services/api/incident-report/incident-report.api"; import { @@ -127,8 +125,8 @@ const groupByQuestion = ( the answers on every "Next step" / "Back" (queued while offline, sent in order later) - notes are sent when they are saved, edited or deleted (queued while offline, after the report) - media files are uploaded as soon as they are picked (that needs a connection) - - "Send report" on the review saves it with isCompleted: true, which locks it (the server refuses - further changes) + - "Send report" (any question, one tap) saves it with isCompleted: true, which locks + it (the server refuses further changes) A draft can be continued later from the report list (incidentReportId param). */ const IncidentReport = () => { @@ -153,7 +151,6 @@ const IncidentReport = () => { const { visits, activeElectionRound } = useUserData(); const { isOnline } = useNetInfoContext(); const { uploadCameraOrMedia } = useCamera(); - const bottomInset = useBottomInset(); const [incidentReportId] = useState(params.incidentReportId ?? Crypto.randomUUID()); @@ -431,7 +428,7 @@ const IncidentReport = () => { saveDraft(answers); const firstQuestion = findNextQuestion(-1, answers); if (!firstQuestion) { - // a form without questions: nothing to fill in, the report is sent right away + // a form without questions: nothing to fill in, the report can be sent right away sendReport(answers); return; } @@ -475,8 +472,8 @@ const IncidentReport = () => { } }; - // the report can be sent at any point, in one tap (no review step: an observer in danger must be - // able to send what they have right away), with whatever was filled in so far + // the report can be sent at any point, in one tap (no review step: an observer in danger must be able + // to send what they have right away), with whatever was filled in so far const onSendNow = (formValues: QuestionFormValues) => { sendReport(commitAnswer(formValues)); }; @@ -499,6 +496,11 @@ const IncidentReport = () => { Keyboard.dismiss(); saveReport(buildReportPayload(reportAnswers, true)); + Toast.show({ + type: "success", + text2: isOnline ? t("sending.sent") : t("sending.queued"), + visibilityTime: 4000, + }); router.back(); }; @@ -779,41 +781,13 @@ const IncidentReport = () => { onActionButtonPress={handleLocationSubmit(onSubmitLocation)} /> ) : ( - - {showPrevious || !isLastQuestion ? ( - - {showPrevious ? ( - - ) : ( - false - )} - {!isLastQuestion ? ( - - ) : ( - false - )} - - ) : ( - false - )} - {/* the main action: sends the report as it is, at any question */} - - + ) } > @@ -821,6 +795,28 @@ const IncidentReport = () => { title={formTitle} leftIcon={} onLeftPress={handleGoBack} + rightAction={ + // one tap sends the report as it is, from any question; kept up here, away from "Next" + step === "questions" ? ( + + + + {t("send")} + + + ) : undefined + } /> {step === "location" && ( diff --git a/mobile/assets/icons/send.svg b/mobile/assets/icons/send.svg new file mode 100644 index 000000000..e36abf0b1 --- /dev/null +++ b/mobile/assets/icons/send.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/assets/locales/az/translations_AZ.json b/mobile/assets/locales/az/translations_AZ.json index 36834ec0f..ed6ffeeb9 100644 --- a/mobile/assets/locales/az/translations_AZ.json +++ b/mobile/assets/locales/az/translations_AZ.json @@ -732,6 +732,11 @@ "progress_bar_label": "Formanın tərəqqisi", "previous_question": "Əvvəlki sual", "next_question": "Növbəti sual", + "send": "Göndər", + "sending": { + "sent": "Hesabat göndərildi", + "queued": "Hesabat yadda saxlanıldı. İnternetə qoşulan kimi göndəriləcək." + }, "error": "İnsident hesabatı göndərilərkən problem baş verdi.", "report_type_sheet": { "title": "Nə barədə məlumat vermək istəyirsiniz?", diff --git a/mobile/assets/locales/bg/translations_BG.json b/mobile/assets/locales/bg/translations_BG.json index 03e09cd15..99e49fb5e 100644 --- a/mobile/assets/locales/bg/translations_BG.json +++ b/mobile/assets/locales/bg/translations_BG.json @@ -735,6 +735,11 @@ "progress_bar_label": "Прогрес на формуляра", "previous_question": "Предишен въпрос", "next_question": "Следващ въпрос", + "send": "Изпрати", + "sending": { + "sent": "Докладът е изпратен", + "queued": "Докладът е запазен. Ще бъде изпратен веднага щом сте отново онлайн." + }, "error": "Възникна проблем при изпращането на доклада за инцидент.", "report_type_sheet": { "title": "Какво искате да докладвате?", diff --git a/mobile/assets/locales/de/translations_DE.json b/mobile/assets/locales/de/translations_DE.json index 31f96ceac..d44b8b2f0 100644 --- a/mobile/assets/locales/de/translations_DE.json +++ b/mobile/assets/locales/de/translations_DE.json @@ -735,6 +735,11 @@ "progress_bar_label": "Formularfortschritt", "previous_question": "Vorherige Frage", "next_question": "Nächste Frage", + "send": "Senden", + "sending": { + "sent": "Bericht gesendet", + "queued": "Bericht gespeichert. Er wird gesendet, sobald du wieder online bist." + }, "error": "Beim Senden des Vorfallsberichts ist ein Problem aufgetreten.", "report_type_sheet": { "title": "Was möchten Sie melden?", diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 2af2f406e..33ccd1b1d 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -735,6 +735,11 @@ "progress_bar_label": "Form progress", "previous_question": "Previous question", "next_question": "Next question", + "send": "Send", + "sending": { + "sent": "Report sent", + "queued": "Report saved. It will be sent as soon as you are back online." + }, "error": "There was a problem while sending the incident report.", "report_type_sheet": { "title": "What do you want to report?", diff --git a/mobile/assets/locales/es/translations_ES.json b/mobile/assets/locales/es/translations_ES.json index 92336beae..3f45f62bb 100644 --- a/mobile/assets/locales/es/translations_ES.json +++ b/mobile/assets/locales/es/translations_ES.json @@ -734,6 +734,11 @@ "progress_bar_label": "Progreso del formulario", "previous_question": "Pregunta anterior", "next_question": "Siguiente pregunta", + "send": "Enviar", + "sending": { + "sent": "Informe enviado", + "queued": "Informe guardado. Se enviará en cuanto vuelvas a estar en línea." + }, "error": "Hubo un problema al enviar el informe de incidente.", "report_type_sheet": { "title": "¿Qué quieres reportar?", diff --git a/mobile/assets/locales/hy/translations_HY.json b/mobile/assets/locales/hy/translations_HY.json index dfbd31737..68e3a564b 100644 --- a/mobile/assets/locales/hy/translations_HY.json +++ b/mobile/assets/locales/hy/translations_HY.json @@ -735,6 +735,11 @@ "progress_bar_label": "Ձևաթղթի առաջընթացը", "previous_question": "Նախորդ հարց", "next_question": "Հաջորդ հարց", + "send": "Ուղարկել", + "sending": { + "sent": "Զեկույցն ուղարկված է", + "queued": "Զեկույցը պահպանված է։ Այն կուղարկվի, հենց վերադառնաք առցանց։" + }, "error": "Միջադեպի մասին հաղորդումն ուղարկելիս սխալ է տեղի ունեցել:", "report_type_sheet": { "title": "Ի՞նչի մասին եք ցանկանում հաղորդել:", diff --git a/mobile/assets/locales/ka/translations_KA.json b/mobile/assets/locales/ka/translations_KA.json index 018ce5d31..777feaada 100644 --- a/mobile/assets/locales/ka/translations_KA.json +++ b/mobile/assets/locales/ka/translations_KA.json @@ -731,6 +731,11 @@ "progress_bar_label": "ფორმის პროგრესი", "previous_question": "წინა კითხვა", "next_question": "შემდეგი კითხვა", + "send": "გაგზავნა", + "sending": { + "sent": "ანგარიში გაიგზავნა", + "queued": "ანგარიში შენახულია. ის გაიგზავნება, როგორც კი ინტერნეტს დაუბრუნდებით." + }, "error": "ინციდენტის ანგარიშის გაგზავნისას დაფიქსირდა შეცდომა.", "report_type_sheet": { "title": "რის შესახებ გსურთ შეტყობინება?", diff --git a/mobile/assets/locales/pl/translations_PL.json b/mobile/assets/locales/pl/translations_PL.json index b01cb1833..77dff939d 100644 --- a/mobile/assets/locales/pl/translations_PL.json +++ b/mobile/assets/locales/pl/translations_PL.json @@ -739,6 +739,11 @@ "progress_bar_label": "Postęp formularza", "previous_question": "Poprzednie pytanie", "next_question": "Następne pytanie", + "send": "Wyślij", + "sending": { + "sent": "Raport wysłany", + "queued": "Raport zapisany. Zostanie wysłany, gdy tylko wrócisz do sieci." + }, "error": "Wystąpił problem podczas wysyłania raportu o incydencie.", "report_type_sheet": { "title": "Co chcesz zgłosić?", diff --git a/mobile/assets/locales/pt-BR/translations_PT-BR.json b/mobile/assets/locales/pt-BR/translations_PT-BR.json index 5a862b1f3..c294a4a39 100644 --- a/mobile/assets/locales/pt-BR/translations_PT-BR.json +++ b/mobile/assets/locales/pt-BR/translations_PT-BR.json @@ -735,6 +735,11 @@ "progress_bar_label": "Progresso do formulário", "previous_question": "Pergunta anterior", "next_question": "Próxima pergunta", + "send": "Enviar", + "sending": { + "sent": "Relatório enviado", + "queued": "Relatório salvo. Ele será enviado assim que você estiver online." + }, "error": "Houve um problema ao enviar o relatório de incidente.", "report_type_sheet": { "title": "O que você deseja relatar?", diff --git a/mobile/assets/locales/ro/translations.json b/mobile/assets/locales/ro/translations.json index 4656ecdf6..a6c069ce7 100644 --- a/mobile/assets/locales/ro/translations.json +++ b/mobile/assets/locales/ro/translations.json @@ -735,6 +735,11 @@ "progress_bar_label": "Progresul formularului", "previous_question": "Întrebarea anterioară", "next_question": "Întrebarea următoare", + "send": "Trimite", + "sending": { + "sent": "Raport trimis", + "queued": "Raport salvat. Va fi trimis imediat ce revii online." + }, "error": "A apărut o problemă la trimiterea raportului de incident.", "report_type_sheet": { "title": "Ce vrei să raportezi?", diff --git a/mobile/assets/locales/ru/translations_RU.json b/mobile/assets/locales/ru/translations_RU.json index f86e4ec75..d82177d23 100644 --- a/mobile/assets/locales/ru/translations_RU.json +++ b/mobile/assets/locales/ru/translations_RU.json @@ -732,6 +732,11 @@ "progress_bar_label": "Прогресс формы", "previous_question": "Предыдущий вопрос", "next_question": "Следующий вопрос", + "send": "Отправить", + "sending": { + "sent": "Отчёт отправлен", + "queued": "Отчёт сохранён. Он будет отправлен, как только появится подключение." + }, "error": "При отправке отчета об инциденте возникла проблема.", "report_type_sheet": { "title": "О чем вы хотите сообщить?", diff --git a/mobile/assets/locales/sr/translations_SR.json b/mobile/assets/locales/sr/translations_SR.json index 08545951f..d496362b2 100644 --- a/mobile/assets/locales/sr/translations_SR.json +++ b/mobile/assets/locales/sr/translations_SR.json @@ -732,6 +732,11 @@ "progress_bar_label": "Napredak obrasca", "previous_question": "Prethodno pitanje", "next_question": "Sledeće pitanje", + "send": "Pošalji", + "sending": { + "sent": "Izveštaj je poslat", + "queued": "Izveštaj je sačuvan. Biće poslat čim ponovo budete na mreži." + }, "error": "Došlo je do problema prilikom slanja izveštaja o incidentu.", "report_type_sheet": { "title": "Šta želite da prijavite?", diff --git a/mobile/components/Header.tsx b/mobile/components/Header.tsx index 44234aebc..6df9d3f6d 100644 --- a/mobile/components/Header.tsx +++ b/mobile/components/Header.tsx @@ -46,6 +46,11 @@ interface HeaderProps { * What happens when you press the right icon or text action. */ onRightPress?: TouchableOpacityProps["onPress"]; + /** + * A wider element (e.g. a labelled button) shown on the right instead of `rightIcon`. + * It takes the width it needs (up to 45%) and the title wraps / shrinks to fit next to it. + */ + rightAction?: React.ReactNode; /** * children */ @@ -68,6 +73,7 @@ const Header = ({ onLeftPress, rightIcon, onRightPress, + rightAction, children, }: HeaderProps) => { const insets = useSafeAreaInsets(); @@ -97,13 +103,17 @@ const Header = ({ {/* right icon */} - - {rightIcon || null} - + {rightAction ? ( + {rightAction} + ) : ( + + {rightIcon || null} + + )} {children} @@ -133,4 +143,13 @@ const $rightIconContainer: ViewStyle = { justifyContent: "flex-end", }; +const $rightActionContainer: ViewStyle = { + flexShrink: 0, + maxWidth: "45%", + paddingVertical: tokens.space.sm.val, + paddingLeft: tokens.space.xs.val, + paddingRight: 14, + justifyContent: "flex-end", +}; + export default Header; diff --git a/mobile/components/Icon.tsx b/mobile/components/Icon.tsx index ff884a1c1..d6816f141 100644 --- a/mobile/components/Icon.tsx +++ b/mobile/components/Icon.tsx @@ -17,6 +17,7 @@ import XCircle from "../assets/icons/x-circle.svg"; import MenuAlt2 from "../assets/icons/menu-alt-2.svg"; import DotsVertical from "../assets/icons/dots-vertical.svg"; import Check from "../assets/icons/check.svg"; +import Send from "../assets/icons/send.svg"; import Calendar from "../assets/icons/Calendar.svg"; import Clock from "../assets/icons/Clock.svg"; import PeopleAddingVote from "../assets/icons/people-adding-vote.svg"; @@ -126,6 +127,7 @@ export const defaultIcon = (props: IconProps): React.ReactElement => { menuAlt2: , dotsVertical: , check: , + send: , calendar: , clock: , peopleAddingVote: , diff --git a/mobile/components/WizzardControls.tsx b/mobile/components/WizzardControls.tsx index 6042d927c..db11b2977 100644 --- a/mobile/components/WizzardControls.tsx +++ b/mobile/components/WizzardControls.tsx @@ -11,6 +11,7 @@ interface WizzardControlsProps extends XStackProps { onPreviousButtonPress?: () => void; onActionButtonPress?: () => void; actionBtnLabel?: string; + previousBtnLabel?: string; actionBtnPreset?: "default" | "outlined" | "chromeless" | "yellow" | "red"; } @@ -21,6 +22,7 @@ const WizzardControls = ({ onPreviousButtonPress, onActionButtonPress, actionBtnLabel, + previousBtnLabel, actionBtnPreset = "default", ...rest }: WizzardControlsProps) => { @@ -47,7 +49,7 @@ const WizzardControls = ({ onPress={onPreviousButtonPress} textStyle={{ textAlign: "center" }} > - {t("actions.back")} + {previousBtnLabel || t("actions.back")} )} diff --git a/mobile/toast.config.js b/mobile/toast.config.js index 55eec7d20..1c64ed3bb 100644 --- a/mobile/toast.config.js +++ b/mobile/toast.config.js @@ -17,7 +17,7 @@ export const toastConfig = { style={{ borderLeftColor: "hsl(142, 71%, 27%)", height: "auto", - backgroundColor: "whsl(142, 71%, 27%)", + backgroundColor: "hsl(142, 71%, 27%)", maxWidth: "90%", }} /> From c8c1b8dd8ba427e1d2cb3b632afdbdab46b4ab33 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Mon, 9 Nov 2026 10:23:07 +0200 Subject: [PATCH 19/21] Mobile: incident report form menu, language switch and locked sent reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The incident report now behaves like the other forms: - the header has the form menu (⋮) with "Change language" for forms that have translations; the questions and the title switch right away, the answers are kept and the choice is saved as the form's language - "Send report" (one tap) sits under the progress bar instead of the header, so the header's rightAction slot is removed again A sent report can no longer be edited: - while its send is still pending (queued offline or behind earlier saves) a refetch of the report list keeps it completed, with the answers that were sent, instead of turning it back into a draft - once "Send report" is pressed the screen saves nothing more (e.g. a second tap on Next) --- .../app/(observer)/(app)/incident-report.tsx | 96 +++++++++++++------ mobile/assets/locales/az/translations_AZ.json | 2 +- mobile/assets/locales/bg/translations_BG.json | 2 +- mobile/assets/locales/de/translations_DE.json | 2 +- mobile/assets/locales/en/translations.json | 2 +- mobile/assets/locales/es/translations_ES.json | 2 +- mobile/assets/locales/hy/translations_HY.json | 2 +- mobile/assets/locales/ka/translations_KA.json | 2 +- mobile/assets/locales/pl/translations_PL.json | 2 +- .../locales/pt-BR/translations_PT-BR.json | 2 +- mobile/assets/locales/ro/translations.json | 2 +- mobile/assets/locales/ru/translations_RU.json | 2 +- mobile/assets/locales/sr/translations_SR.json | 2 +- mobile/components/Header.tsx | 33 ++----- .../queries/incident-reports.query.ts | 37 ++++++- 15 files changed, 123 insertions(+), 67 deletions(-) diff --git a/mobile/app/(observer)/(app)/incident-report.tsx b/mobile/app/(observer)/(app)/incident-report.tsx index 5d3c40d93..3fc040d0e 100644 --- a/mobile/app/(observer)/(app)/incident-report.tsx +++ b/mobile/app/(observer)/(app)/incident-report.tsx @@ -26,11 +26,14 @@ import { MediaDialog } from "../../../components/MediaDialog"; import MediaLoading from "../../../components/MediaLoading"; import NoteForm from "../../../components/NoteForm"; import OptionsSheet from "../../../components/OptionsSheet"; +import ChangeLanguageDialog from "../../../components/ChangeLanguageDialog"; +import { setFormLanguagePreference } from "../../../common/language.preferences"; import QuestionForm from "../../../components/QuestionForm"; import { Screen } from "../../../components/Screen"; import { ScreenScrollView } from "../../../components/ScreenScrollView"; import Select from "../../../components/Select"; import { Typography } from "../../../components/Typography"; +import Button from "../../../components/Button"; import WarningDialog from "../../../components/WarningDialog"; import WizzardControls from "../../../components/WizzardControls"; import { useNetInfoContext } from "../../../contexts/net-info-banner/NetInfoContext"; @@ -144,6 +147,7 @@ const IncidentReport = () => { "report_new_issue", "report_details", "polling_station_form_wizard", + "languages", ]); const queryClient = useQueryClient(); const cancelRef = useRef(false); @@ -174,6 +178,8 @@ const IncidentReport = () => { const [isSaved, setIsSaved] = useState(isResuming); // whether the report is known to exist on the server, which uploading media needs const isOnServerRef = useRef(isResuming); + // once sent the report is locked: nothing is saved or sent again from this screen (e.g. a second tap) + const isSentRef = useRef(false); const [isInitialized, setIsInitialized] = useState(!isResuming); const [step, setStep] = useState<"location" | "questions">(isResuming ? "questions" : "location"); @@ -218,14 +224,24 @@ const IncidentReport = () => { }); const locationWatch = watchLocation("location"); - // the language picked when choosing the form, if the form still has it + // the language picked when choosing the form (or switched to here), if the form still has it + const [selectedLanguage, setSelectedLanguage] = useState(languageParam); + const [isChangeLanguageOpen, setIsChangeLanguageOpen] = useState(false); + const [isFormMenuOpen, setIsFormMenuOpen] = useState(false); const language = useMemo( () => - languageParam && currentForm?.languages.includes(languageParam) - ? languageParam + selectedLanguage && currentForm?.languages.includes(selectedLanguage) + ? selectedLanguage : getFormLanguage(currentForm), - [currentForm, languageParam], + [currentForm, selectedLanguage], ); + + // only changes how the questions are shown: answers are kept by question / option id + const onChangeLanguage = (formId: string, newLanguage: string) => { + setFormLanguagePreference({ formId, language: newLanguage }); + setSelectedLanguage(newLanguage); + setIsChangeLanguageOpen(false); + }; const formTitle = useMemo( () => (currentForm ? `${getFormDisplayName(currentForm, language)} (${language})` : ""), [currentForm, language], @@ -408,7 +424,7 @@ const IncidentReport = () => { // saves the draft; queued (and sent in order with the notes) while offline const saveDraft = (reportAnswers: Record) => { - if (!activeElectionRound || !currentForm) return; + if (!activeElectionRound || !currentForm || isSentRef.current) return; saveReport(buildReportPayload(reportAnswers, false), { onSuccess: () => { isOnServerRef.current = true; @@ -492,7 +508,8 @@ const IncidentReport = () => { // never waits for the server: the save is queued (with the earlier ones) and sent as soon as there // is a connection, retried while there is none, and kept if the app is closed while offline const sendReport = (reportAnswers: Record) => { - if (!activeElectionRound || !currentForm) return; + if (!activeElectionRound || !currentForm || isSentRef.current) return; + isSentRef.current = true; Keyboard.dismiss(); saveReport(buildReportPayload(reportAnswers, true)); @@ -765,6 +782,8 @@ const IncidentReport = () => { !!activeQuestion && findNextQuestion(activeQuestion.indexInAllQuestions, answers) === null; // once saved, the location can not be changed any more const showPrevious = !isFirstQuestion || !isSaved; + // the form menu, as on the other forms (for now it only changes the language) + const hasFormMenu = step === "questions" && (currentForm?.languages.length ?? 0) > 1; return ( { title={formTitle} leftIcon={} onLeftPress={handleGoBack} - rightAction={ - // one tap sends the report as it is, from any question; kept up here, away from "Next" - step === "questions" ? ( - - - - {t("send")} - - - ) : undefined + rightIcon={hasFormMenu ? : undefined} + onRightPress={ + hasFormMenu + ? () => { + Keyboard.dismiss(); + setIsFormMenuOpen(true); + } + : undefined } /> @@ -891,6 +897,15 @@ const IncidentReport = () => { current={activeQuestion.indexInDisplayedQuestions + 1} total={displayedQuestions.length} /> + {/* one tap sends the report as it is, from any question; kept away from "Next" */} + @@ -1095,6 +1110,33 @@ const IncidentReport = () => { )} + {isFormMenuOpen && ( + + + { + setIsFormMenuOpen(false); + setIsChangeLanguageOpen(true); + }} + > + {t("change_language")} + + + + )} + + {isChangeLanguageOpen && currentForm && ( + setIsChangeLanguageOpen(false)} + /> + )} + {showWarningDialog && ( { const insets = useSafeAreaInsets(); @@ -103,17 +97,13 @@ const Header = ({ {/* right icon */} - {rightAction ? ( - {rightAction} - ) : ( - - {rightIcon || null} - - )} + + {rightIcon || null} + {children} @@ -143,13 +133,4 @@ const $rightIconContainer: ViewStyle = { justifyContent: "flex-end", }; -const $rightActionContainer: ViewStyle = { - flexShrink: 0, - maxWidth: "45%", - paddingVertical: tokens.space.sm.val, - paddingLeft: tokens.space.xs.val, - paddingRight: 14, - justifyContent: "flex-end", -}; - export default Header; diff --git a/mobile/services/queries/incident-reports.query.ts b/mobile/services/queries/incident-reports.query.ts index 3038f07d2..88fa36a9a 100644 --- a/mobile/services/queries/incident-reports.query.ts +++ b/mobile/services/queries/incident-reports.query.ts @@ -1,9 +1,10 @@ -import { skipToken, useQuery } from "@tanstack/react-query"; +import { QueryClient, skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { getIncidentReportAttachments, getMyIncidentReports, IncidentReportAPIResponse, + UpsertIncidentReportAPIPayload, } from "../api/incident-report/incident-report.api"; import { ElectionRoundsAllFormsAPIResponse, @@ -37,13 +38,45 @@ export const IncidentReportKeys = { addAttachmentAbort: () => [...IncidentReportKeys.all, "addAttachmentAbort"], }; +/* + A sent report is locked as soon as "Send report" is pressed, even before the server has it: while + that save is still pending (queued offline, or behind the report's earlier saves) the server still + returns the report as a draft, so a refetch would make it editable again. Those reports keep what + was sent, as completed. +*/ +const withPendingSends = ( + queryClient: QueryClient, + reports: IncidentReportAPIResponse[], +): IncidentReportAPIResponse[] => { + const pendingSends = new Map( + queryClient + .getMutationCache() + .findAll({ mutationKey: IncidentReportKeys.add(), status: "pending" }) + .map((mutation) => mutation.state.variables as UpsertIncidentReportAPIPayload | undefined) + .filter((payload) => !!payload?.isCompleted) + .map((payload) => [payload!.id, payload!]), + ); + if (!pendingSends.size) return reports; + + return reports.map((report) => { + const sent = pendingSends.get(report.id); + return sent && !report.isCompleted + ? { ...report, answers: sent.answers, isCompleted: true, isNotSynched: true } + : report; + }); +}; + export const useIncidentReports = ( electionRoundId?: string, select?: (data: IncidentReportAPIResponse[]) => TResult, ) => { + const queryClient = useQueryClient(); + return useQuery({ queryKey: IncidentReportKeys.byElectionRound(electionRoundId), - queryFn: electionRoundId ? () => getMyIncidentReports(electionRoundId) : skipToken, + queryFn: electionRoundId + ? async () => withPendingSends(queryClient, await getMyIncidentReports(electionRoundId)) + : skipToken, select, }); }; From 1a1aa95abbd524d0b38233f1fd2d8cd0d81cefac Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 10 Nov 2026 10:05:02 +0200 Subject: [PATCH 20/21] Mobile: address PR review comments, date answer values and tests Review comments: - date answers saved before the day picker are full UTC instants: show the local day of that instant instead of its first 10 characters (2 May 01:30 in UTC+3, stored as 2024-05-01T22:30Z, showed 1 May) - incident report saves stop retrying after 10 network failures while online (offline still pauses them), so notes queued behind are not blocked forever; a send that fails for good shows a toast - the persisted cache no longer stores request errors: a query whose refetch failed is restored with its data as a success, and the AxiosError (with the auth header in its config) is not written - Sentry only ignores 404s from the endpoints where it means there is nothing to fetch, so a broken endpoint is reported again - the Android date-time picker drops the seconds - the polling station questionnaire uses the shared answer mapping - memoized date/time format context value; CodeQL useless conditionals Date, time and date-time answers (common/utils/date-answers.ts): - date-time: the picked moment as a UTC instant, without seconds - date: the day shown, time: the time shown (no time zone shift) Tests: npm test runs tests/ with node --test (Node 22.18+): date answers in several time zones, Sentry error filter, persisted cache and incident report retries. --- .../(app)/polling-station-questionnaire.tsx | 124 +++------------ mobile/assets/locales/az/translations_AZ.json | 3 +- mobile/assets/locales/bg/translations_BG.json | 3 +- mobile/assets/locales/de/translations_DE.json | 3 +- mobile/assets/locales/en/translations.json | 3 +- mobile/assets/locales/es/translations_ES.json | 3 +- mobile/assets/locales/hy/translations_HY.json | 3 +- mobile/assets/locales/ka/translations_KA.json | 3 +- mobile/assets/locales/pl/translations_PL.json | 3 +- .../locales/pt-BR/translations_PT-BR.json | 3 +- mobile/assets/locales/ro/translations.json | 3 +- mobile/assets/locales/ru/translations_RU.json | 3 +- mobile/assets/locales/sr/translations_SR.json | 3 +- mobile/common/utils/date-answers.ts | 57 +++++++ mobile/common/utils/time.ts | 8 +- mobile/components/Inputs/DateInput.tsx | 3 +- mobile/components/TimeSelect.tsx | 4 +- .../DateTimeFormatContext.provider.tsx | 28 ++-- .../PersistQueryContext.provider.tsx | 18 +++ .../persist-query/persisted-client.ts | 34 +++++ mobile/package.json | 3 +- mobile/services/api-errors.ts | 51 +++++++ mobile/services/api.ts | 25 +-- mobile/services/form.parser.ts | 38 ++--- .../add-incident-report.mutation.ts | 8 +- mobile/tests/api-errors.test.ts | 87 +++++++++++ mobile/tests/date-answers.test.ts | 142 ++++++++++++++++++ mobile/tests/incident-report-retry.test.ts | 109 ++++++++++++++ mobile/tests/persisted-client.test.ts | 112 ++++++++++++++ mobile/tsconfig.json | 4 +- 30 files changed, 695 insertions(+), 196 deletions(-) create mode 100644 mobile/common/utils/date-answers.ts create mode 100644 mobile/contexts/persist-query/persisted-client.ts create mode 100644 mobile/services/api-errors.ts create mode 100644 mobile/tests/api-errors.test.ts create mode 100644 mobile/tests/date-answers.test.ts create mode 100644 mobile/tests/incident-report-retry.test.ts create mode 100644 mobile/tests/persisted-client.test.ts diff --git a/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx b/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx index 22213f438..cad27d089 100644 --- a/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx +++ b/mobile/app/(observer)/(app)/polling-station-questionnaire.tsx @@ -25,12 +25,10 @@ import { Icon } from "../../../components/Icon"; import { useTranslation } from "react-i18next"; import Header from "../../../components/Header"; import { - fromDayIso, - fromTimeOfDay, mapAPIAnswersToFormAnswers, mapAPIQuestionsToFormQuestions, - toDayIso, - toTimeOfDay, + mapFormSubmissionDataToAPIFormSubmissionAnswer, + setFormDefaultValues as answerToFormValue, withAnswerTimestamps, } from "../../../services/form.parser"; import Button from "../../../components/Button"; @@ -130,73 +128,19 @@ const PollingStationQuestionnaire = () => { const question: ApiFormQuestion = questions[questionId]; if (!question) return undefined; - if (!formData[questionId]) return undefined; - - switch (FormQuestionAnswerTypeMapping[question.$questionType]) { - case "numberAnswer": - return { - $answerType: "numberAnswer", - questionId, - value: +formData[questionId], - } as ApiFormAnswer; - case "ratingAnswer": - return { - $answerType: "ratingAnswer", - questionId, - value: +formData[questionId], - } as ApiFormAnswer; - case "textAnswer": - return { - $answerType: "textAnswer", - questionId, - text: formData[questionId], - } as ApiFormAnswer; - case "dateAnswer": - return { - $answerType: "dateAnswer", - questionId, - date: toDayIso(new Date(formData[questionId] as string)), - } as ApiFormAnswer; - case "timeAnswer": - return { - $answerType: "timeAnswer", - questionId, - time: toTimeOfDay(new Date(formData[questionId] as string)), - } as ApiFormAnswer; - case "dateTimeAnswer": - return { - $answerType: "dateTimeAnswer", - questionId, - dateTime: new Date(formData[questionId] as string).toISOString(), - } as ApiFormAnswer; - case "singleSelectAnswer": - return { - $answerType: "singleSelectAnswer", - questionId, - selection: { - optionId: (formData[questionId] as Record).radioValue, - text: (formData[questionId] as Record).textValue, - }, - } as ApiFormAnswer; - case "multiSelectAnswer": { - const selections: Record = formData[ - questionId - ] as Record; - const mappedSelections = Object.values(selections).map((selection) => ({ - optionId: selection.optionId, - text: selection.text, - })); - return mappedSelections?.length - ? ({ - $answerType: "multiSelectAnswer", - questionId, - selection: mappedSelections, - } as ApiFormAnswer) - : undefined; - } - default: - return undefined; + + const answer = mapFormSubmissionDataToAPIFormSubmissionAnswer( + questionId, + question.$questionType, + formData[questionId] as Parameters< + typeof mapFormSubmissionDataToAPIFormSubmissionAnswer + >[2], + ); + // a multi select question with nothing picked has no answer + if (answer?.$answerType === "multiSelectAnswer" && !answer.selection.length) { + return undefined; } + return answer; }) .map((answer) => answer && withAnswerTimestamps(answer, answers[answer.questionId])) .filter(Boolean) as ApiFormAnswer[]; @@ -237,42 +181,10 @@ const PollingStationQuestionnaire = () => { const setFormDefaultValues = () => { const formFields: Record = Object.keys(answers).reduce( - (acc: Record, questionId: string) => { - const answer = answers[questionId]; - switch (answer.$answerType) { - case "textAnswer": - acc[questionId] = answer.text; - break; - case "numberAnswer": - case "ratingAnswer": - acc[questionId] = answer?.value?.toString() ?? ""; - break; - case "dateAnswer": - acc[questionId] = answer.date ? fromDayIso(answer.date) : ""; - break; - case "timeAnswer": - acc[questionId] = answer.time ? fromTimeOfDay(answer.time) : ""; - break; - case "dateTimeAnswer": - acc[questionId] = answer.dateTime ? new Date(answer.dateTime) : ""; - break; - case "singleSelectAnswer": - acc[questionId] = { - radioValue: answer.selection.optionId, - textValue: answer.selection.text, - }; - break; - case "multiSelectAnswer": - acc[questionId] = answer.selection.reduce((acc: Record, curr) => { - acc[curr.optionId] = { optionId: curr.optionId, text: curr.text }; - return acc; - }, {}); - break; - default: - break; - } - return acc; - }, + (acc: Record, questionId: string) => ({ + ...acc, + ...answerToFormValue(questionId, answers[questionId]), + }), {}, ); diff --git a/mobile/assets/locales/az/translations_AZ.json b/mobile/assets/locales/az/translations_AZ.json index b2ac72aff..349b12c3c 100644 --- a/mobile/assets/locales/az/translations_AZ.json +++ b/mobile/assets/locales/az/translations_AZ.json @@ -735,7 +735,8 @@ "change_language": "Dili dəyiş", "sending": { "sent": "Hesabat göndərildi", - "queued": "Hesabat yadda saxlanıldı. İnternetə qoşulan kimi göndəriləcək." + "queued": "Hesabat yadda saxlanıldı. İnternetə qoşulan kimi göndəriləcək.", + "failed": "İnsident hesabatı göndərilə bilmədi. Onu hesabatlar siyahısından açın və yenidən göndərin." }, "error": "İnsident hesabatı göndərilərkən problem baş verdi.", "report_type_sheet": { diff --git a/mobile/assets/locales/bg/translations_BG.json b/mobile/assets/locales/bg/translations_BG.json index 9a13b611f..281ced877 100644 --- a/mobile/assets/locales/bg/translations_BG.json +++ b/mobile/assets/locales/bg/translations_BG.json @@ -738,7 +738,8 @@ "change_language": "Смяна на езика", "sending": { "sent": "Докладът е изпратен", - "queued": "Докладът е запазен. Ще бъде изпратен веднага щом сте отново онлайн." + "queued": "Докладът е запазен. Ще бъде изпратен веднага щом сте отново онлайн.", + "failed": "Докладът за инцидент не можа да бъде изпратен. Отворете го от списъка с доклади и го изпратете отново." }, "error": "Възникна проблем при изпращането на доклада за инцидент.", "report_type_sheet": { diff --git a/mobile/assets/locales/de/translations_DE.json b/mobile/assets/locales/de/translations_DE.json index 9b4ef287a..c4931c838 100644 --- a/mobile/assets/locales/de/translations_DE.json +++ b/mobile/assets/locales/de/translations_DE.json @@ -738,7 +738,8 @@ "change_language": "Sprache ändern", "sending": { "sent": "Bericht gesendet", - "queued": "Bericht gespeichert. Er wird gesendet, sobald du wieder online bist." + "queued": "Bericht gespeichert. Er wird gesendet, sobald du wieder online bist.", + "failed": "Der Vorfallbericht konnte nicht gesendet werden. Öffne ihn in der Berichtsliste und sende ihn erneut." }, "error": "Beim Senden des Vorfallsberichts ist ein Problem aufgetreten.", "report_type_sheet": { diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 2fbc261e6..63e7fdd64 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -738,7 +738,8 @@ "change_language": "Change language", "sending": { "sent": "Report sent", - "queued": "Report saved. It will be sent as soon as you are back online." + "queued": "Report saved. It will be sent as soon as you are back online.", + "failed": "Your incident report could not be sent. Open it from the report list and send it again." }, "error": "There was a problem while sending the incident report.", "report_type_sheet": { diff --git a/mobile/assets/locales/es/translations_ES.json b/mobile/assets/locales/es/translations_ES.json index 8f58f36c3..4028ef21b 100644 --- a/mobile/assets/locales/es/translations_ES.json +++ b/mobile/assets/locales/es/translations_ES.json @@ -737,7 +737,8 @@ "change_language": "Cambiar idioma", "sending": { "sent": "Informe enviado", - "queued": "Informe guardado. Se enviará en cuanto vuelvas a estar en línea." + "queued": "Informe guardado. Se enviará en cuanto vuelvas a estar en línea.", + "failed": "No se pudo enviar el informe de incidente. Ábrelo desde la lista de informes y envíalo de nuevo." }, "error": "Hubo un problema al enviar el informe de incidente.", "report_type_sheet": { diff --git a/mobile/assets/locales/hy/translations_HY.json b/mobile/assets/locales/hy/translations_HY.json index 339f16fff..14930c506 100644 --- a/mobile/assets/locales/hy/translations_HY.json +++ b/mobile/assets/locales/hy/translations_HY.json @@ -738,7 +738,8 @@ "change_language": "Փոխել լեզուն", "sending": { "sent": "Զեկույցն ուղարկված է", - "queued": "Զեկույցը պահպանված է։ Այն կուղարկվի, հենց վերադառնաք առցանց։" + "queued": "Զեկույցը պահպանված է։ Այն կուղարկվի, հենց վերադառնաք առցանց։", + "failed": "Միջադեպի զեկույցը չհաջողվեց ուղարկել։ Բացեք այն զեկույցների ցանկից և կրկին ուղարկեք։" }, "error": "Միջադեպի մասին հաղորդումն ուղարկելիս սխալ է տեղի ունեցել:", "report_type_sheet": { diff --git a/mobile/assets/locales/ka/translations_KA.json b/mobile/assets/locales/ka/translations_KA.json index b3e6e9266..38276b7c3 100644 --- a/mobile/assets/locales/ka/translations_KA.json +++ b/mobile/assets/locales/ka/translations_KA.json @@ -734,7 +734,8 @@ "change_language": "ენის შეცვლა", "sending": { "sent": "ანგარიში გაიგზავნა", - "queued": "ანგარიში შენახულია. ის გაიგზავნება, როგორც კი ინტერნეტს დაუბრუნდებით." + "queued": "ანგარიში შენახულია. ის გაიგზავნება, როგორც კი ინტერნეტს დაუბრუნდებით.", + "failed": "ინციდენტის ანგარიში ვერ გაიგზავნა. გახსენით ის ანგარიშების სიიდან და ხელახლა გააგზავნეთ." }, "error": "ინციდენტის ანგარიშის გაგზავნისას დაფიქსირდა შეცდომა.", "report_type_sheet": { diff --git a/mobile/assets/locales/pl/translations_PL.json b/mobile/assets/locales/pl/translations_PL.json index 7e026c60b..0b5159e05 100644 --- a/mobile/assets/locales/pl/translations_PL.json +++ b/mobile/assets/locales/pl/translations_PL.json @@ -742,7 +742,8 @@ "change_language": "Zmień język", "sending": { "sent": "Raport wysłany", - "queued": "Raport zapisany. Zostanie wysłany, gdy tylko wrócisz do sieci." + "queued": "Raport zapisany. Zostanie wysłany, gdy tylko wrócisz do sieci.", + "failed": "Nie udało się wysłać raportu o incydencie. Otwórz go z listy raportów i wyślij ponownie." }, "error": "Wystąpił problem podczas wysyłania raportu o incydencie.", "report_type_sheet": { diff --git a/mobile/assets/locales/pt-BR/translations_PT-BR.json b/mobile/assets/locales/pt-BR/translations_PT-BR.json index 5c4fb7877..96bcdc777 100644 --- a/mobile/assets/locales/pt-BR/translations_PT-BR.json +++ b/mobile/assets/locales/pt-BR/translations_PT-BR.json @@ -738,7 +738,8 @@ "change_language": "Alterar idioma", "sending": { "sent": "Relatório enviado", - "queued": "Relatório salvo. Ele será enviado assim que você estiver online." + "queued": "Relatório salvo. Ele será enviado assim que você estiver online.", + "failed": "Não foi possível enviar o relatório de incidente. Abra-o na lista de relatórios e envie novamente." }, "error": "Houve um problema ao enviar o relatório de incidente.", "report_type_sheet": { diff --git a/mobile/assets/locales/ro/translations.json b/mobile/assets/locales/ro/translations.json index d1ff00b86..94e384dde 100644 --- a/mobile/assets/locales/ro/translations.json +++ b/mobile/assets/locales/ro/translations.json @@ -738,7 +738,8 @@ "change_language": "Schimbă limba", "sending": { "sent": "Raport trimis", - "queued": "Raport salvat. Va fi trimis imediat ce revii online." + "queued": "Raport salvat. Va fi trimis imediat ce revii online.", + "failed": "Raportul de incident nu a putut fi trimis. Deschide-l din lista de rapoarte și trimite-l din nou." }, "error": "A apărut o problemă la trimiterea raportului de incident.", "report_type_sheet": { diff --git a/mobile/assets/locales/ru/translations_RU.json b/mobile/assets/locales/ru/translations_RU.json index 22e260ae7..c166dc634 100644 --- a/mobile/assets/locales/ru/translations_RU.json +++ b/mobile/assets/locales/ru/translations_RU.json @@ -735,7 +735,8 @@ "change_language": "Сменить язык", "sending": { "sent": "Отчёт отправлен", - "queued": "Отчёт сохранён. Он будет отправлен, как только появится подключение." + "queued": "Отчёт сохранён. Он будет отправлен, как только появится подключение.", + "failed": "Не удалось отправить отчёт об инциденте. Откройте его в списке отчётов и отправьте ещё раз." }, "error": "При отправке отчета об инциденте возникла проблема.", "report_type_sheet": { diff --git a/mobile/assets/locales/sr/translations_SR.json b/mobile/assets/locales/sr/translations_SR.json index e7e1384f4..62bfecd39 100644 --- a/mobile/assets/locales/sr/translations_SR.json +++ b/mobile/assets/locales/sr/translations_SR.json @@ -735,7 +735,8 @@ "change_language": "Promeni jezik", "sending": { "sent": "Izveštaj je poslat", - "queued": "Izveštaj je sačuvan. Biće poslat čim ponovo budete na mreži." + "queued": "Izveštaj je sačuvan. Biće poslat čim ponovo budete na mreži.", + "failed": "Izveštaj o incidentu nije mogao da bude poslat. Otvorite ga iz liste izveštaja i pošaljite ponovo." }, "error": "Došlo je do problema prilikom slanja izveštaja o incidentu.", "report_type_sheet": { diff --git a/mobile/common/utils/date-answers.ts b/mobile/common/utils/date-answers.ts new file mode 100644 index 000000000..5b3418429 --- /dev/null +++ b/mobile/common/utils/date-answers.ts @@ -0,0 +1,57 @@ +/* + The values of date, time and date-time answers: + - date-time: the moment the observer picked, as a UTC instant to the minute (no seconds) + - date: the day as shown to the observer, "YYYY-MM-DDT00:00:00Z" (no time zone shift) + - time: the time of day as shown to the observer, "HH:mm:ss" (no date, no time zone) +*/ + +// a date answer is the picked day at midnight UTC ("YYYY-MM-DDT00:00:00Z", or just "YYYY-MM-DD") +const DAY_AT_MIDNIGHT_UTC = /^\d{4}-\d{2}-\d{2}(T00:00(:00(\.0+)?)?(Z|[+-]00:?00)?)?$/; + +/** + * The local day of a date answer. Answers saved before the date picker became a day picker are the + * full UTC instant that was picked (e.g. 2 May 01:30 in UTC+3 is "2024-05-01T22:30:00Z"): for those + * the local day of that instant counts, not its first 10 characters (which would be a day earlier). + * Null when the value is not a date. + */ +export const dayOfIso = (value: string): Date | null => { + if (!DAY_AT_MIDNIGHT_UTC.test(value)) { + const instant = new Date(value); + if (!Number.isNaN(instant.getTime())) { + return new Date(instant.getFullYear(), instant.getMonth(), instant.getDate()); + } + } + if (!/^\d{4}-\d{2}-\d{2}/.test(value)) return null; + const [year, month, day] = value.slice(0, 10).split("-").map(Number); + return new Date(year, month - 1, day); +}; + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** + * The picked moment as a UTC instant, to the minute (pickers only set hours and minutes: leftover + * seconds are dropped), e.g. "2024-05-01T22:30:00.000Z" for 2 May 01:30 in UTC+3. + */ +export const toDateTimeIso = (date: Date) => { + const minute = new Date(date); + minute.setSeconds(0, 0); + return minute.toISOString(); +}; + +/** The picked local day as "YYYY-MM-DDT00:00:00Z": the API keeps the date part as is, with no time zone shift. */ +export const toDayIso = (date: Date) => + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T00:00:00Z`; + +/** A date answer back to a local Date on that day (see dayOfIso for answers saved by older versions). */ +export const fromDayIso = (value: string) => dayOfIso(value) ?? new Date(NaN); + +/** The picked local time as "HH:mm:ss" (a time of day, without a date or time zone). */ +export const toTimeOfDay = (date: Date) => `${pad(date.getHours())}:${pad(date.getMinutes())}:00`; + +/** A time answer back to a Date today at that time, for the picker. */ +export const fromTimeOfDay = (value: string) => { + const [hours, minutes] = value.split(":").map(Number); + const date = new Date(); + date.setHours(hours || 0, minutes || 0, 0, 0); + return date; +}; diff --git a/mobile/common/utils/time.ts b/mobile/common/utils/time.ts index e54565ad2..1a42db4fb 100644 --- a/mobile/common/utils/time.ts +++ b/mobile/common/utils/time.ts @@ -1,4 +1,5 @@ import { format } from "date-fns"; +import { dayOfIso } from "./date-answers"; // the date formats the user can pick in settings, as date-fns patterns export const DATE_FORMATS = ["yyyy-MM-dd", "dd.MM.yyyy", "dd/MM/yyyy", "MM/dd/yyyy"] as const; @@ -15,11 +16,10 @@ export const formatTime = (value: Date, is24Hour: boolean) => /** A calendar day in the chosen date format, e.g. "2026-11-03" or "03.11.2026". */ export const formatDate = (value: Date, dateFormat: DateFormat) => format(value, dateFormat); -/** A calendar day stored as "YYYY-MM-DD", shown in the chosen date format without a time zone shift. */ +/** A date answer (see dayOfIso), shown in the chosen date format without a time zone shift. */ export const formatDayIso = (day: string, dateFormat: DateFormat) => { - const [year, month, date] = day.slice(0, 10).split("-").map(Number); - if ([year, month, date].some(Number.isNaN)) return day.slice(0, 10); - return formatDate(new Date(year, month - 1, date), dateFormat); + const date = dayOfIso(day); + return date ? formatDate(date, dateFormat) : day.slice(0, 10); }; /** A wall-clock time stored as "HH:mm" or "HH:mm:ss", shown in the chosen clock format. */ diff --git a/mobile/components/Inputs/DateInput.tsx b/mobile/components/Inputs/DateInput.tsx index 86907cb1b..437caf823 100644 --- a/mobile/components/Inputs/DateInput.tsx +++ b/mobile/components/Inputs/DateInput.tsx @@ -79,7 +79,8 @@ export const DateInput: React.FC = ({ onChange: (event, eventTime) => { if (eventTime && selectedDate) { selectedDate.setHours(eventTime.getHours()); - selectedDate.setMinutes(eventTime.getMinutes()); + // no seconds: picking the same date and time again is not a change to sync + selectedDate.setMinutes(eventTime.getMinutes(), 0, 0); onChange(selectedDate); } }, diff --git a/mobile/components/TimeSelect.tsx b/mobile/components/TimeSelect.tsx index b8ff6ce06..5ca8dc1d3 100644 --- a/mobile/components/TimeSelect.tsx +++ b/mobile/components/TimeSelect.tsx @@ -152,10 +152,10 @@ const TimeSelect: React.FC = memo( {time ? ( - {time && formatDate(time)} + {formatDate(time)} - {time && formatTime(time)} + {formatTime(time)} ) : ( diff --git a/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx b/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx index cdf5e5576..ea5d18149 100644 --- a/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx +++ b/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useMemo, useState } from "react"; import { SECURE_STORAGE_KEYS } from "../../common/constants"; import { DATE_FORMATS, @@ -50,20 +50,20 @@ const DateTimeFormatContextProvider = ({ children }: React.PropsWithChildren) => setSecureStoreItem(SECURE_STORAGE_KEYS.DATE_FORMAT, value); }; - return ( - formatTime(value, is24Hour), - formatDate: (value: Date) => formatDate(value, dateFormat), - }} - > - {children} - + // a new value only when a format changes, so consumers don't re-render with every root render + const value = useMemo( + () => ({ + is24Hour, + setIs24Hour, + dateFormat, + setDateFormat, + formatTime: (date: Date) => formatTime(date, is24Hour), + formatDate: (date: Date) => formatDate(date, dateFormat), + }), + [is24Hour, dateFormat], ); + + return {children}; }; export const useDateTimeFormat = () => { diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index 93a4ec65c..1c05a5918 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -4,12 +4,14 @@ import { QueryClient, defaultShouldDehydrateMutation, defaultShouldDehydrateQuery, + matchMutation, } from "@tanstack/react-query"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { notesKeys, pollingStationsKeys } from "../../services/queries.service"; import * as API from "../../services/definitions.api"; import { PersistGate } from "../../components/PersistGate"; +import { withoutRequestErrors } from "./persisted-client"; import { deleteAttachment } from "../../services/api/delete-attachment.api"; import { Note } from "../../common/models/note"; import { QuickReportKeys } from "../../services/queries/quick-reports.query"; @@ -23,6 +25,8 @@ import { AttachmentsKeys } from "../../services/queries/attachments.query"; import { ASYNC_STORAGE_KEYS, MUTATION_SCOPE_DO_NOT_HYDRATE } from "../../common/constants"; import * as Sentry from "@sentry/react-native"; import SuperJSON from "superjson"; +import Toast from "react-native-toast-message"; +import i18n from "../../common/config/i18n"; import { QuickReportAttachmentAPIResponse } from "../../services/api/quick-report/get-quick-reports.api"; import { IncidentReportKeys } from "../../services/queries/incident-reports.query"; import { @@ -47,6 +51,19 @@ const queryClient = new QueryClient({ SuperJSON.stringify(mutation), ); Sentry.captureException(error); + + // "Send report" already told the observer the report was sent: say that it was not after all + const payload = mutation.state.variables as AddIncidentReportMutationPayload | undefined; + if ( + matchMutation({ mutationKey: IncidentReportKeys.add() }, mutation) && + payload?.isCompleted + ) { + Toast.show({ + type: "error", + text2: i18n.t("sending.failed", { ns: "incident_report" }), + visibilityTime: 8000, + }); + } }, }), defaultOptions: { @@ -99,6 +116,7 @@ const persister = createAsyncStoragePersister({ storage: AsyncStorage, throttleTime: 1000, key: ASYNC_STORAGE_KEYS.REACT_QUERY_OFFLINE_CACHE, + serialize: (client) => JSON.stringify(withoutRequestErrors(client)), }); const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { diff --git a/mobile/contexts/persist-query/persisted-client.ts b/mobile/contexts/persist-query/persisted-client.ts new file mode 100644 index 000000000..00ecfc21e --- /dev/null +++ b/mobile/contexts/persist-query/persisted-client.ts @@ -0,0 +1,34 @@ +import type { PersistedClient } from "@tanstack/react-query-persist-client"; + +/* + What is written to storage, without the errors of failed requests: + - a query whose last refetch failed but that still has its data (kept so the app starts offline with + data) is stored as a success, so after a restart screens show the data and not an error + - a paused mutation keeps its payload but not the error of its last attempt + The errors are AxiosErrors: serialized, they carry the request config, auth headers included. +*/ +export const withoutRequestErrors = (client: PersistedClient): PersistedClient => ({ + ...client, + clientState: { + ...client.clientState, + queries: client.clientState.queries.map((query) => + query.state.status === "error" && query.state.data !== undefined + ? { + ...query, + state: { + ...query.state, + status: "success", + error: null, + errorUpdateCount: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + }, + } + : query, + ), + mutations: client.clientState.mutations.map((mutation) => ({ + ...mutation, + state: { ...mutation.state, error: null, failureReason: null }, + })), + }, +}); diff --git a/mobile/package.json b/mobile/package.json index 7f6351593..70fd15fa1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -20,7 +20,8 @@ "eas-update-production": "dotenv -e .env.production -- node scripts/publish-update.js", "eas-update": "dotenv -e .env -- node scripts/publish-update.js", "build-and-bump": "dotenv -e .env -- node scripts/build.js", - "postinstall": "patch-package" + "postinstall": "patch-package", + "test": "node --test \"tests/**/*.test.ts\"" }, "dependencies": { "@hookform/resolvers": "^3.3.4", diff --git a/mobile/services/api-errors.ts b/mobile/services/api-errors.ts new file mode 100644 index 000000000..ab4e91ed0 --- /dev/null +++ b/mobile/services/api-errors.ts @@ -0,0 +1,51 @@ +import axios from "axios"; + +const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +// Replace ids so the same endpoint is grouped into a single Sentry issue +export const normalizeUrl = (url?: string) => + (url ?? "").split("?")[0].replace(UUID_REGEX, "{id}").replace(/^\//, ""); + +/** + * Endpoints that answer 404 when there is nothing to fetch (e.g. a round without citizen reporting forms, + * locations or a polling station information form). Anywhere else a 404 is a real error and is reported. + */ +const EXPECTED_NOT_FOUND = new Set([ + "election-rounds/{id}/citizen-reporting-forms", + "election-rounds/{id}/locations:version", + "election-rounds/{id}/locations:fetchAll", + "election-rounds/{id}/polling-stations:version", + "election-rounds/{id}/polling-stations:fetchAll", + "election-rounds/{id}/polling-station-information-form", +]); + +// the request got no response: no connectivity, an unreachable server or a timeout +export const isNetworkError = (error: unknown) => + axios.isAxiosError(error) && !error.response && !axios.isCancel(error); + +/** + * Timeouts and requests without a response (no connectivity) are expected on observers' phones, and + * the queries retry them; so is a 404 from an endpoint where it means "nothing there" (EXPECTED_NOT_FOUND). + * Reporting those only buries real errors: Sentry's beforeSend drops them wherever they're captured. + */ +export const isExpectedApiError = (error: unknown) => { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + if (status === 404) return EXPECTED_NOT_FOUND.has(normalizeUrl(error.config?.url)); + return !status && !!error.request; + } + // fetch (uploads, push tokens) without connectivity + return error instanceof TypeError && error.message === "Network request failed"; +}; + +/* + Incident report saves (the report and its notes) must not be lost when the connection drops: a request + that got no response is retried with a growing delay. While offline the retries are paused (that does + not use them up); with a connection but no answer (server down, timeouts) they stop after + MAX_NETWORK_RETRIES (~3.5 minutes with retryDelay), so the saves queued behind it in the report's scope (notes) are + not blocked forever. A send that fails for good is shown to the observer (PersistQueryContext). + Errors the server answered (e.g. a locked report) are not retried. +*/ +const MAX_NETWORK_RETRIES = 10; +export const retryWhileNoConnection = (failureCount: number, error: unknown) => + isNetworkError(error) && failureCount < MAX_NETWORK_RETRIES; diff --git a/mobile/services/api.ts b/mobile/services/api.ts index 5dab854b7..4e5d41c07 100644 --- a/mobile/services/api.ts +++ b/mobile/services/api.ts @@ -1,4 +1,5 @@ import axios, { AxiosError, AxiosRequestHeaders } from "axios"; +import { isExpectedApiError, isNetworkError, normalizeUrl } from "./api-errors"; import * as Sentry from "@sentry/react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { ASYNC_STORAGE_KEYS } from "../common/constants"; @@ -159,29 +160,7 @@ API.interceptors.response.use( }, ); -const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; - -// Replace ids so the same endpoint is grouped into a single Sentry issue -const normalizeUrl = (url?: string) => - (url ?? "").split("?")[0].replace(UUID_REGEX, "{id}").replace(/^\//, ""); - -/** - * Not found (e.g. a round without citizen reporting forms), timeouts and requests without a response (no - * connectivity) are expected on observers' phones, and the queries retry them: reporting each one only buries real - * errors. Sentry's beforeSend drops them wherever they're captured. - */ -// the request got no response: no connectivity, an unreachable server or a timeout -export const isNetworkError = (error: unknown) => - axios.isAxiosError(error) && !error.response && !axios.isCancel(error); - -export const isExpectedApiError = (error: unknown) => { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - return status === 404 || (!status && !!error.request); - } - // fetch (uploads, push tokens) without connectivity - return error instanceof TypeError && error.message === "Network request failed"; -}; +export { isExpectedApiError, isNetworkError }; const reportApiError = (error: AxiosError) => { if (axios.isCancel(error)) { diff --git a/mobile/services/form.parser.ts b/mobile/services/form.parser.ts index a31add53f..c3484df78 100644 --- a/mobile/services/form.parser.ts +++ b/mobile/services/form.parser.ts @@ -4,6 +4,13 @@ import { FormListItem } from "../components/SingleSubmissionFormList"; import { MultiSubmissionFormListItem } from "../components/MultiSubmissionFormList"; import { FormSubmissionDetails } from "../components/FormSubmissionListItem"; import { arrayToKeyObject, groupArrayByKey } from "../helpers/misc"; +import { + fromDayIso, + fromTimeOfDay, + toDateTimeIso, + toDayIso, + toTimeOfDay, +} from "../common/utils/date-answers"; import { FormAPIModel, FormSubmission, FormSubmissionsApiResponse } from "./definitions.api"; import { ApiFormAnswer, @@ -48,28 +55,8 @@ const mapDisplayConditionToMath = ( } }; -const pad = (n: number) => String(n).padStart(2, "0"); - -/** The picked local day as "YYYY-MM-DDT00:00:00Z": the API keeps the date part as is, with no time zone shift. */ -export const toDayIso = (date: Date) => - `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T00:00:00Z`; - -/** A date answer back to a local Date on that day (only the first 10 characters count). */ -export const fromDayIso = (value: string) => { - const [year, month, day] = value.slice(0, 10).split("-").map(Number); - return new Date(year, month - 1, day); -}; - -/** The picked local time as "HH:mm:ss" (a time of day, without a date or time zone). */ -export const toTimeOfDay = (date: Date) => `${pad(date.getHours())}:${pad(date.getMinutes())}:00`; - -/** A time answer back to a Date today at that time, for the picker. */ -export const fromTimeOfDay = (value: string) => { - const [hours, minutes] = value.split(":").map(Number); - const date = new Date(); - date.setHours(hours || 0, minutes || 0, 0, 0); - return date; -}; +// the values date, time and date-time questions send and read back (see date-answers) +export { fromDayIso, fromTimeOfDay, toDayIso, toTimeOfDay } from "../common/utils/date-answers"; /** * Whether a select parent's picked options meet a display rule. The rule lists the options in @@ -101,11 +88,12 @@ const answerValueKey = (answer: ApiFormAnswer): string => { case "ratingAnswer": return `${answer.$answerType}:${answer.value}`; case "dateAnswer": - return `date:${answer.date?.slice(0, 10)}`; + return `date:${answer.date ? toDayIso(fromDayIso(answer.date)) : ""}`; case "timeAnswer": return `time:${answer.time?.slice(0, 5)}`; case "dateTimeAnswer": - return `dateTime:${new Date(answer.dateTime).getTime()}`; + // to the minute, like what is sent: an older answer with seconds is not a change + return `dateTime:${toDateTimeIso(new Date(answer.dateTime))}`; case "singleSelectAnswer": return `single:${answer.selection?.optionId}:${answer.selection?.text ?? ""}`; case "multiSelectAnswer": @@ -285,7 +273,7 @@ export const mapFormSubmissionDataToAPIFormSubmissionAnswer = ( return { $answerType: "dateTimeAnswer", questionId, - dateTime: new Date(answer as string | Date).toISOString(), + dateTime: toDateTimeIso(new Date(answer as string | Date)), } as ApiFormAnswer; case "singleSelectAnswer": return { diff --git a/mobile/services/mutations/incident-report/add-incident-report.mutation.ts b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts index 084a6e44d..71ee5b6b5 100644 --- a/mobile/services/mutations/incident-report/add-incident-report.mutation.ts +++ b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts @@ -9,14 +9,8 @@ import { deleteIncidentReportAttachment, } from "../../api/incident-report/incident-report.api"; import { IncidentReportKeys } from "../../queries/incident-reports.query"; -import { isNetworkError } from "../../api"; +import { retryWhileNoConnection } from "../../api-errors"; -/* - Incident report saves (the report and its notes) must not be lost when the connection drops: a request - that got no response is retried (with a growing delay, paused while offline) until it goes through. - Errors the server answered (e.g. a locked report) are not retried. -*/ -const retryWhileNoConnection = (_failureCount: number, error: unknown) => isNetworkError(error); const retryDelay = (attempt: number) => Math.min(1000 * 2 ** attempt, 30 * 1000); // attachments and notes are only used for the optimistic update, they are sent separately diff --git a/mobile/tests/api-errors.test.ts b/mobile/tests/api-errors.test.ts new file mode 100644 index 000000000..bda6b1341 --- /dev/null +++ b/mobile/tests/api-errors.test.ts @@ -0,0 +1,87 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { AxiosError } from "axios"; +import { + isExpectedApiError, + isNetworkError, + retryWhileNoConnection, +} from "../services/api-errors.ts"; + +const ID = "7f1c2a3b-0000-4000-8000-000000000001"; + +const withStatus = (url: string, status: number) => + new AxiosError( + "failed", + "ERR_BAD_REQUEST", + { url, headers: {} } as any, + {}, + { + status, + data: {}, + statusText: "", + headers: {}, + config: { url, headers: {} } as any, + }, + ); + +const noResponse = (url: string, code = "ERR_NETWORK") => + new AxiosError("Network Error", code, { url, headers: {} } as any, {}); + +describe("isExpectedApiError (not reported to Sentry)", () => { + test("404 only from endpoints where it means there is nothing to fetch", () => { + for (const url of [ + `/election-rounds/${ID}/citizen-reporting-forms`, + `/election-rounds/${ID}/locations:version`, + `/election-rounds/${ID}/locations:fetchAll`, + `election-rounds/${ID}/polling-stations:version`, + `election-rounds/${ID}/polling-stations:fetchAll?x=1`, + `election-rounds/${ID}/polling-station-information-form`, + ]) { + assert.equal(isExpectedApiError(withStatus(url, 404)), true, url); + } + }); + + test("a 404 anywhere else is reported (e.g. an endpoint broken by a deploy)", () => { + for (const url of [ + `election-rounds/${ID}/incident-reports`, + `election-rounds/${ID}/incident-reports:my`, + `election-rounds/${ID}/form-submissions:my`, + `election-rounds/${ID}/quick-reports:my`, + ]) { + assert.equal(isExpectedApiError(withStatus(url, 404)), false, url); + } + }); + + test("other statuses are reported, requests without a response are not", () => { + const url = `election-rounds/${ID}/incident-reports`; + assert.equal(isExpectedApiError(withStatus(url, 500)), false); + assert.equal(isExpectedApiError(withStatus(url, 409)), false); + assert.equal(isExpectedApiError(noResponse(url)), true); + assert.equal(isExpectedApiError(noResponse(url, "ECONNABORTED")), true); + assert.equal(isExpectedApiError(new TypeError("Network request failed")), true); + assert.equal(isExpectedApiError(new Error("boom")), false); + }); +}); + +describe("isNetworkError", () => { + test("only requests that got no response", () => { + const url = `election-rounds/${ID}/incident-reports`; + assert.equal(isNetworkError(noResponse(url)), true); + assert.equal(isNetworkError(withStatus(url, 500)), false); + assert.equal(isNetworkError(new Error("x")), false); + }); +}); + +describe("retryWhileNoConnection", () => { + test("retries a request without a response up to 10 times", () => { + const error = noResponse("x"); + assert.equal(retryWhileNoConnection(0, error), true); + assert.equal(retryWhileNoConnection(9, error), true); + assert.equal(retryWhileNoConnection(10, error), false); + }); + + test("never retries an error the server answered", () => { + assert.equal(retryWhileNoConnection(0, withStatus("x", 409)), false); + assert.equal(retryWhileNoConnection(0, withStatus("x", 500)), false); + }); +}); diff --git a/mobile/tests/date-answers.test.ts b/mobile/tests/date-answers.test.ts new file mode 100644 index 000000000..2ab247058 --- /dev/null +++ b/mobile/tests/date-answers.test.ts @@ -0,0 +1,142 @@ +/* + What date, time and date-time questions send and read back, in the observer's own time zone: + - date-time: the picked moment as a UTC instant, to the minute + - date: the day shown, with no time zone shift + - time: the time shown, with no time zone shift + Node applies a change of process.env.TZ right away, so each case runs in several time zones. +*/ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { + dayOfIso, + fromDayIso, + fromTimeOfDay, + toDateTimeIso, + toDayIso, + toTimeOfDay, +} from "../common/utils/date-answers.ts"; + +const TIME_ZONES = [ + "UTC", + "Europe/Bucharest", // UTC+2/+3 + "America/New_York", // UTC-5/-4 + "Asia/Kolkata", // UTC+5:30 + "Asia/Tokyo", // UTC+9 + "Pacific/Kiritimati", // UTC+14 + "Pacific/Pago_Pago", // UTC-11 +]; + +const inEachTimeZone = (name: string, fn: () => void) => { + for (const timeZone of TIME_ZONES) { + test(`${name} [${timeZone}]`, () => { + const previous = process.env.TZ; + process.env.TZ = timeZone; + try { + fn(); + } finally { + process.env.TZ = previous; + } + }); + } +}; + +const ymd = (d: Date | null) => + d && + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +// the pickers return a local Date: the observer picks 2 May 2024 at 00:30, 13:05 or 23:45 on screen +// (with seconds left over, as the Android picker does) +const picked = () => + [ + [0, 30], + [13, 5], + [23, 45], + ].map(([h, m]) => new Date(2024, 4, 2, h, m, 17, 250)); + +describe("date questions send the day shown", () => { + inEachTimeZone("sent value", () => { + for (const date of picked()) { + const sent = toDayIso(date); + assert.equal(sent, "2024-05-02T00:00:00Z"); + // the API (a DateTime) reads it as midnight UTC: still 2 May + assert.equal(new Date(sent).toISOString().slice(0, 10), "2024-05-02"); + // read back: 2 May on screen + assert.equal(ymd(fromDayIso(sent)), "2024-05-02"); + } + }); +}); + +describe("time questions send the time shown", () => { + inEachTimeZone("sent value, without seconds", () => { + assert.deepEqual(picked().map(toTimeOfDay), ["00:30:00", "13:05:00", "23:45:00"]); + for (const date of picked()) { + const back = fromTimeOfDay(toTimeOfDay(date)); + assert.equal( + `${back.getHours()}:${back.getMinutes()}`, + `${date.getHours()}:${date.getMinutes()}`, + ); + } + }); +}); + +describe("date-time questions send the picked moment in UTC, to the minute", () => { + inEachTimeZone("sent value", () => { + for (const date of picked()) { + const sent = toDateTimeIso(date); + assert.match(sent, /:00\.000Z$/); + const minute = new Date(date); + minute.setSeconds(0, 0); + assert.equal(new Date(sent).getTime(), minute.getTime()); + // shown back in the observer's time zone as what they picked + assert.equal(new Date(sent).getHours(), date.getHours()); + assert.equal(date.getSeconds(), 17); // the picked Date itself is not changed + } + }); + + test("2 May 01:30 in Bucharest is sent as 1 May 22:30 UTC", () => { + const previous = process.env.TZ; + process.env.TZ = "Europe/Bucharest"; + try { + assert.equal(toDateTimeIso(new Date(2024, 4, 2, 1, 30, 42, 123)), "2024-05-01T22:30:00.000Z"); + } finally { + process.env.TZ = previous; + } + }); +}); + +describe("date answers saved by older versions", () => { + // the old date picker stored the picked local moment as a full UTC instant: the day shown must be + // the one that was picked, not the first 10 characters (2 May 01:30 in UTC+3 is "2024-05-01T22:30Z") + inEachTimeZone("show the day that was picked", () => { + for (const date of picked()) { + const stored = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + ).toISOString(); + // a value that happens to be exactly midnight UTC is read as a new answer: the same day when + // the picker's time zone is UTC or east of it + const expected = /T00:00:00\.000Z$/.test(stored) ? stored.slice(0, 10) : "2024-05-02"; + assert.equal(ymd(dayOfIso(stored)), expected, stored); + } + }); + + inEachTimeZone("new values keep their day", () => { + for (const value of [ + "2024-05-02T00:00:00Z", + "2024-05-02T00:00:00.000Z", + "2024-05-02T00:00:00", + "2024-05-02T00:00:00+00:00", + "2024-05-02", + ]) { + assert.equal(ymd(dayOfIso(value)), "2024-05-02", value); + } + }); + + test("not a date", () => { + assert.equal(dayOfIso("not a date"), null); + assert.equal(dayOfIso(""), null); + }); +}); diff --git a/mobile/tests/incident-report-retry.test.ts b/mobile/tests/incident-report-retry.test.ts new file mode 100644 index 000000000..3b813e80a --- /dev/null +++ b/mobile/tests/incident-report-retry.test.ts @@ -0,0 +1,109 @@ +/* + How an incident report save behaves with the real retry rule in TanStack Query: it gives up after + 10 retries when there is a connection but no answer, so the saves queued behind it in the report's + scope (notes) still run, and it waits (without using up retries) while offline. +*/ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { AxiosError } from "axios"; +import { MutationObserver, QueryClient, onlineManager } from "@tanstack/react-query"; +import { retryWhileNoConnection } from "../services/api-errors.ts"; + +const networkError = () => new AxiosError("Network Error", "ERR_NETWORK", {} as any, {}); +const conflict = () => + new AxiosError( + "Conflict", + "ERR_BAD_REQUEST", + {} as any, + {}, + { + status: 409, + data: {}, + headers: {}, + statusText: "", + config: {} as any, + }, + ); + +describe("incident report saves", () => { + test("with no answer, a send stops after 11 attempts instead of retrying forever", async () => { + const client = new QueryClient(); + let attempts = 0; + const send = new MutationObserver(client, { + mutationFn: async () => { + attempts++; + throw networkError(); + }, + retry: retryWhileNoConnection, + retryDelay: 0, + scope: { id: "incident-report:1" }, + }); + await send.mutate().catch(() => {}); + assert.equal(attempts, 11); // the first try and 10 retries + assert.equal(send.getCurrentResult().status, "error"); + }); + + test("a note queued behind a send that failed for good still runs", async () => { + const client = new QueryClient(); + let noteRan = false; + const send = new MutationObserver(client, { + mutationFn: async () => { + throw networkError(); + }, + retry: retryWhileNoConnection, + retryDelay: 0, + scope: { id: "incident-report:2" }, + }); + const note = new MutationObserver(client, { + mutationFn: async () => { + noteRan = true; + }, + scope: { id: "incident-report:2" }, + }); + await Promise.all([send.mutate().catch(() => {}), note.mutate()]); + assert.equal(noteRan, true); + }); + + test("an error the server answered (e.g. a locked report) is not retried", async () => { + const client = new QueryClient(); + let attempts = 0; + const save = new MutationObserver(client, { + mutationFn: async () => { + attempts++; + throw conflict(); + }, + retry: retryWhileNoConnection, + retryDelay: 0, + }); + await save.mutate().catch(() => {}); + assert.equal(attempts, 1); + }); + + test("offline, a send waits without using up retries and goes through once back online", async () => { + const client = new QueryClient(); + client.mount(); + let attempts = 0; + onlineManager.setOnline(false); + try { + const send = new MutationObserver(client, { + mutationFn: async () => { + attempts++; + return "sent"; + }, + retry: retryWhileNoConnection, + retryDelay: 0, + }); + const result = send.mutate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(attempts, 0); + assert.equal(send.getCurrentResult().isPaused, true); + + onlineManager.setOnline(true); + assert.equal(await result, "sent"); + assert.equal(attempts, 1); + } finally { + onlineManager.setOnline(true); + client.unmount(); + } + }); +}); diff --git a/mobile/tests/persisted-client.test.ts b/mobile/tests/persisted-client.test.ts new file mode 100644 index 000000000..ccd78ced2 --- /dev/null +++ b/mobile/tests/persisted-client.test.ts @@ -0,0 +1,112 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { AxiosError } from "axios"; +import { + QueryClient, + defaultShouldDehydrateQuery, + dehydrate, + hydrate, + type Query, +} from "@tanstack/react-query"; +import { withoutRequestErrors } from "../contexts/persist-query/persisted-client.ts"; + +const networkError = () => + new AxiosError( + "Network Error", + "ERR_NETWORK", + { + url: "election-rounds/1/incident-reports:my", + headers: { Authorization: "Bearer SECRET-TOKEN" }, + } as any, + {}, + ); + +// the same rule as PersistQueryContext's shouldDehydrateQuery +const shouldDehydrateQuery = (query: Query) => + defaultShouldDehydrateQuery(query) || + (query.state.status === "error" && query.state.data !== undefined); + +describe("withoutRequestErrors (what is written to storage)", () => { + test("a query whose refetch failed is kept with its data, as a success, without the error", async () => { + const client = new QueryClient(); + const key = ["incident-reports", "1"]; + await client.fetchQuery({ queryKey: key, queryFn: async () => [{ id: "a" }] }); + await client + .fetchQuery({ + queryKey: key, + queryFn: async () => { + throw networkError(); + }, + retry: false, + staleTime: 0, + }) + .catch(() => {}); + assert.equal(client.getQueryState(key)?.status, "error"); + + const persisted = { + timestamp: Date.now(), + buster: "", + clientState: dehydrate(client, { shouldDehydrateQuery }), + }; + + // without it, the AxiosError would be written with its request config, auth header included, + // and the query would come back after a restart as an error + const raw = JSON.stringify(persisted); + assert.match(raw, /SECRET-TOKEN/); + const restoredRaw = new QueryClient(); + hydrate(restoredRaw, JSON.parse(raw).clientState); + assert.equal(restoredRaw.getQueryState(key)?.status, "error"); + + const stored = JSON.stringify(withoutRequestErrors(persisted)); + assert.doesNotMatch(stored, /SECRET-TOKEN|Network Error/); + const restored = new QueryClient(); + hydrate(restored, JSON.parse(stored).clientState); + assert.equal(restored.getQueryState(key)?.status, "success"); + assert.equal(restored.getQueryState(key)?.error, null); + assert.deepEqual(restored.getQueryData(key), [{ id: "a" }]); + }); + + test("successful queries and failed ones without data are left as they are", () => { + const ok = { + queryKey: ["a"], + queryHash: "a", + state: { status: "success", data: 1, error: null }, + }; + const failedNoData = { + queryKey: ["b"], + queryHash: "b", + state: { status: "error", data: undefined, error: {} }, + }; + const out = withoutRequestErrors({ + timestamp: 0, + buster: "", + clientState: { queries: [ok, failedNoData], mutations: [] }, + } as any); + assert.deepEqual(out.clientState.queries, [ok, failedNoData]); + }); + + test("a paused mutation keeps its payload but not the error of its last attempt", () => { + const out = withoutRequestErrors({ + timestamp: 0, + buster: "", + clientState: { + queries: [], + mutations: [ + { + mutationKey: ["incident-reports", "add"], + state: { + variables: { id: "r1" }, + isPaused: true, + error: null, + failureReason: networkError(), + }, + }, + ], + }, + } as any); + const [mutation] = out.clientState.mutations; + assert.deepEqual(mutation.state.variables, { id: "r1" }); + assert.equal(mutation.state.failureReason, null); + assert.doesNotMatch(JSON.stringify(out), /SECRET-TOKEN/); + }); +}); diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index bbcf2bf77..2df57823d 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -2,7 +2,9 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, - "experimentalDecorators": true + "experimentalDecorators": true, + // tests run with node --test, which needs the .ts extension in imports (noEmit: never compiled) + "allowImportingTsExtensions": true // "strictPropertyInitialization": false } } From ba886d7a232f7ea05e66d4cfe7825e8b57a16098 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Thu, 12 Nov 2026 10:19:41 +0200 Subject: [PATCH 21/21] Mobile: send the incident report from the form menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Send report" moves from under the progress bar to the form menu (⋮), which is now shown on every question; "Change language" stays there for forms with translations. Sending still takes the answer on screen and locks the report in one tap; on the last question "Next" still becomes "Send report". The send icon, no longer used, is removed. --- .../app/(observer)/(app)/incident-report.tsx | 32 +++++++++++-------- mobile/assets/icons/send.svg | 5 --- mobile/components/Icon.tsx | 2 -- 3 files changed, 18 insertions(+), 21 deletions(-) delete mode 100644 mobile/assets/icons/send.svg diff --git a/mobile/app/(observer)/(app)/incident-report.tsx b/mobile/app/(observer)/(app)/incident-report.tsx index 3fc040d0e..bccc676dd 100644 --- a/mobile/app/(observer)/(app)/incident-report.tsx +++ b/mobile/app/(observer)/(app)/incident-report.tsx @@ -33,7 +33,6 @@ import { Screen } from "../../../components/Screen"; import { ScreenScrollView } from "../../../components/ScreenScrollView"; import Select from "../../../components/Select"; import { Typography } from "../../../components/Typography"; -import Button from "../../../components/Button"; import WarningDialog from "../../../components/WarningDialog"; import WizzardControls from "../../../components/WizzardControls"; import { useNetInfoContext } from "../../../contexts/net-info-banner/NetInfoContext"; @@ -782,8 +781,9 @@ const IncidentReport = () => { !!activeQuestion && findNextQuestion(activeQuestion.indexInAllQuestions, answers) === null; // once saved, the location can not be changed any more const showPrevious = !isFirstQuestion || !isSaved; - // the form menu, as on the other forms (for now it only changes the language) - const hasFormMenu = step === "questions" && (currentForm?.languages.length ?? 0) > 1; + // the form menu, as on the other forms: send the report (from any question) and change the language + const hasFormMenu = step === "questions"; + const hasTranslations = (currentForm?.languages.length ?? 0) > 1; return ( { current={activeQuestion.indexInDisplayedQuestions + 1} total={displayedQuestions.length} /> - {/* one tap sends the report as it is, from any question; kept away from "Next" */} - @@ -1113,16 +1104,29 @@ const IncidentReport = () => { {isFormMenuOpen && ( + {/* sends the report as it is (with the answer on screen) and locks it, in one tap */} { setIsFormMenuOpen(false); - setIsChangeLanguageOpen(true); + handleQuestionSubmit(onSendNow)(); }} > - {t("change_language")} + {t("review.send")} + {hasTranslations && ( + { + setIsFormMenuOpen(false); + setIsChangeLanguageOpen(true); + }} + > + {t("change_language")} + + )} )} diff --git a/mobile/assets/icons/send.svg b/mobile/assets/icons/send.svg deleted file mode 100644 index e36abf0b1..000000000 --- a/mobile/assets/icons/send.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/mobile/components/Icon.tsx b/mobile/components/Icon.tsx index d6816f141..ff884a1c1 100644 --- a/mobile/components/Icon.tsx +++ b/mobile/components/Icon.tsx @@ -17,7 +17,6 @@ import XCircle from "../assets/icons/x-circle.svg"; import MenuAlt2 from "../assets/icons/menu-alt-2.svg"; import DotsVertical from "../assets/icons/dots-vertical.svg"; import Check from "../assets/icons/check.svg"; -import Send from "../assets/icons/send.svg"; import Calendar from "../assets/icons/Calendar.svg"; import Clock from "../assets/icons/Clock.svg"; import PeopleAddingVote from "../assets/icons/people-adding-vote.svg"; @@ -127,7 +126,6 @@ export const defaultIcon = (props: IconProps): React.ReactElement => { menuAlt2: , dotsVertical: , check: , - send: , calendar: , clock: , peopleAddingVote: ,