From 2ce00eb5f7c1b6fa90e7e0e711665456f6075a79 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Wed, 16 Oct 2024 15:56:13 +0300 Subject: [PATCH 1/4] feat: [upload-v2] wip for citizen --- .../(app)/form-questionnaire/[questionId].tsx | 15 +- mobile/app/citizen/main/form/index.tsx | 162 ++++++++++++++++-- mobile/assets/locales/en/translations.json | 21 +++ mobile/components/ReviewCitizenFormSheet.tsx | 13 +- 4 files changed, 191 insertions(+), 20 deletions(-) diff --git a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx index 5a6b15144..32160e3de 100644 --- a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx @@ -58,7 +58,7 @@ export type SearchParamType = { }; const FormQuestionnaire = () => { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); const { t } = useTranslation(["polling_station_form_wizard", "common"]); const { questionId, formId, language } = useLocalSearchParams(); @@ -369,7 +369,6 @@ const FormQuestionnaire = () => { activeQuestion.question.id ) { try { - const totalParts = Math.ceil(cameraResult.size! / MULTIPART_FILE_UPLOAD_SIZE); const attachmentId = Crypto.randomUUID(); const payload: AddAttachmentStartAPIPayload = { @@ -394,7 +393,7 @@ const FormQuestionnaire = () => { totalParts, ); setUploadProgress(t("attachments.upload.completed")); - setIsOptionsSheetOpen(false) + setIsOptionsSheetOpen(false); } catch (err) { Sentry.captureException(err, { data: activeElectionRound }); } @@ -434,7 +433,7 @@ const FormQuestionnaire = () => { const data = await addAttachmentStart(payload); await handleChunkUpload(file.uri, data.uploadUrls, data.uploadId, payload.id, totalParts); setUploadProgress(t("attachments.upload.completed")); - setIsOptionsSheetOpen(false) + setIsOptionsSheetOpen(false); } catch (err) { Sentry.captureException(err, { data: activeElectionRound }); } @@ -475,7 +474,13 @@ const FormQuestionnaire = () => { electionRoundId: activeElectionRound?.id, id: attachmentId, }); - queryClient.invalidateQueries({ queryKey: AttachmentsKeys.attachments(activeElectionRound?.id, selectedPollingStation?.pollingStationId, formId) }) + queryClient.invalidateQueries({ + queryKey: AttachmentsKeys.attachments( + activeElectionRound?.id, + selectedPollingStation?.pollingStationId, + formId, + ), + }); } } catch (err) { Sentry.captureException(err, { data: activeElectionRound }); diff --git a/mobile/app/citizen/main/form/index.tsx b/mobile/app/citizen/main/form/index.tsx index 8b902c7d1..5689d9bd5 100644 --- a/mobile/app/citizen/main/form/index.tsx +++ b/mobile/app/citizen/main/form/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { ScrollView, XStack, YStack } from "tamagui"; +import { Card, ScrollView, XStack, YStack } from "tamagui"; import { Screen } from "../../../../components/Screen"; import Header from "../../../../components/Header"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -26,6 +26,14 @@ import Toast from "react-native-toast-message"; import ReviewCitizenFormSheet from "../../../../components/ReviewCitizenFormSheet"; import WarningDialog from "../../../../components/WarningDialog"; import i18n from "../../../../common/config/i18n"; +import AddAttachment from "../../../../components/AddAttachment"; +import OptionsSheet from "../../../../components/OptionsSheet"; +import MediaLoading from "../../../../components/MediaLoading"; +import { FileMetadata, useCamera } from "../../../../hooks/useCamera"; +import * as DocumentPicker from "expo-document-picker"; +import * as Crypto from "expo-crypto"; +// import { Buffer } from "buffer"; +// import * as FileSystem from "expo-file-system"; const CitizenForm = () => { const { t } = useTranslation(["citizen_form", "network_banner"]); @@ -36,6 +44,13 @@ const CitizenForm = () => { const { selectedElectionRound } = useCitizenUserData(); const { isOnline } = useNetInfoContext(); + const [isOptionsSheetOpen, setIsOptionsSheetOpen] = useState(false); + const [isPreparingFile, setIsPreparingFile] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); + const [attachments, setAttachments] = useState>({}); + + const { uploadCameraOrMedia } = useCamera(); + if (!selectedElectionRound) { return ( @@ -227,6 +242,66 @@ const CitizenForm = () => { } }; + const onCompressionProgress = (progress: number) => { + setUploadProgress(`${t("attachments.upload.compressing")} ${Math.ceil(progress * 100)}%`); + }; + + const removeAttachmentLocal = (id: string): void => { + setAttachments((prevAttachments) => { + return { + ...prevAttachments, + [questionId]: prevAttachments[questionId].filter(attachment => attachment.id !== id) + }; + }); + }; + + const handleCameraUpload = async (type: "library" | "cameraPhoto") => { + setIsPreparingFile(true); + setUploadProgress(t("attachments.upload.preparing")); + const cameraResult = await uploadCameraOrMedia(type, onCompressionProgress); + + if (!cameraResult) { + setIsPreparingFile(false); + return; + } + + setIsOptionsSheetOpen(false); + setAttachments((prevAttachments) => ({ + ...prevAttachments, + [questionId]: prevAttachments[questionId] ? [...prevAttachments[questionId], { fileMetadata: cameraResult, id: Crypto.randomUUID() }] : [{ fileMetadata: cameraResult, id: Crypto.randomUUID() }], + })); + setIsPreparingFile(false); + }; + + const handleUploadAudio = async () => { + const doc = await DocumentPicker.getDocumentAsync({ + type: "audio/*", + multiple: false, + }); + + if (doc?.assets?.[0]) { + const file = doc?.assets?.[0]; + + const fileMetadata: FileMetadata = { + name: file.name, + type: file.mimeType || "audio/mpeg", + uri: file.uri, + size: file.size || 0, + }; + + setIsOptionsSheetOpen(false); + setAttachments((prevAttachments) => ({ + ...prevAttachments, + [questionId]: prevAttachments[questionId] ? [...prevAttachments[questionId], { fileMetadata, id: Crypto.randomUUID() }] : [{ fileMetadata, id: Crypto.randomUUID() }], + })); + setIsPreparingFile(false); + } else { + // Cancelled + } + + setIsPreparingFile(false); + }; + if (isLoadingCurrentForm || !activeQuestion) { return Loading...; } @@ -273,24 +348,47 @@ const CitizenForm = () => { required={true} /> - {/* attachments */} - {/* {activeElectionRound?.id && selectedPollingStation?.pollingStationId && formId && ( - - )} */} - - {/* + {t("attachments.heading")} + + {attachments[questionId].map((attachment) => { + return ( + + + {attachment.fileMetadata.name} + + + + + + ); + })} + + + ) : ( + false + )} + + { Keyboard.dismiss(); - return setIsOptionsSheetOpen(true); + setIsOptionsSheetOpen(true); }} - /> */} + /> { currentForm={currentForm} answers={answers} questions={currentForm?.questions} + attachments={attachments} setIsReviewSheetOpen={setIsReviewSheetOpen} selectedLocationId={selectedLocationId} /> )} + {isOptionsSheetOpen && ( + + {false || isPreparingFile ? ( + + ) : ( + + + {t("attachments.menu.load")} + + + {t("attachments.menu.take_picture")} + + + {t("attachments.menu.upload_audio")} + + + )} + + )} + {showWarningDialog && ( { const { t } = useTranslation("citizen_form"); @@ -36,6 +37,7 @@ export default function ReviewCitizenFormSheet({ currentForm, answers, questions, + attachments, setIsReviewSheetOpen, selectedLocationId, language, @@ -43,6 +45,7 @@ export default function ReviewCitizenFormSheet({ currentForm: FormAPIModel | undefined; answers: Record | undefined; questions: ApiFormQuestion[] | undefined; + attachments: Record | undefined; setIsReviewSheetOpen: Dispatch>; selectedLocationId: string; language: string; @@ -188,6 +191,14 @@ export default function ReviewCitizenFormSheet({ {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} )} + {attachments && attachments[question.id] ? ( + + {t("attachments.heading")}: {attachments[question.id].length} + + ) : ( + false + )} + ))} From fd4d3b46122b5b3dc960348391c1922ff735ba88 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Thu, 17 Oct 2024 13:31:25 +0300 Subject: [PATCH 2/4] fix: [upload-v2] add working version for citizen report --- mobile/app/citizen/main/form/index.tsx | 20 +- mobile/assets/locales/ro/translations.json | 22 ++ mobile/components/ReviewCitizenFormSheet.tsx | 217 ++++++++++++++---- .../services/api/citizen/attachments.api.ts | 82 +++++++ .../add-attachment-citizen.mutation.ts | 20 ++ mobile/services/queries/citizen.query.ts | 1 + 6 files changed, 310 insertions(+), 52 deletions(-) create mode 100644 mobile/services/api/citizen/attachments.api.ts create mode 100644 mobile/services/mutations/citizen/add-attachment-citizen.mutation.ts diff --git a/mobile/app/citizen/main/form/index.tsx b/mobile/app/citizen/main/form/index.tsx index 5689d9bd5..dbdacafe0 100644 --- a/mobile/app/citizen/main/form/index.tsx +++ b/mobile/app/citizen/main/form/index.tsx @@ -47,7 +47,9 @@ const CitizenForm = () => { const [isOptionsSheetOpen, setIsOptionsSheetOpen] = useState(false); const [isPreparingFile, setIsPreparingFile] = useState(false); const [uploadProgress, setUploadProgress] = useState(""); - const [attachments, setAttachments] = useState>({}); + const [attachments, setAttachments] = useState< + Record + >({}); const { uploadCameraOrMedia } = useCamera(); @@ -250,7 +252,7 @@ const CitizenForm = () => { setAttachments((prevAttachments) => { return { ...prevAttachments, - [questionId]: prevAttachments[questionId].filter(attachment => attachment.id !== id) + [questionId]: prevAttachments[questionId].filter((attachment) => attachment.id !== id), }; }); }; @@ -268,7 +270,9 @@ const CitizenForm = () => { setIsOptionsSheetOpen(false); setAttachments((prevAttachments) => ({ ...prevAttachments, - [questionId]: prevAttachments[questionId] ? [...prevAttachments[questionId], { fileMetadata: cameraResult, id: Crypto.randomUUID() }] : [{ fileMetadata: cameraResult, id: Crypto.randomUUID() }], + [questionId]: prevAttachments[questionId] + ? [...prevAttachments[questionId], { fileMetadata: cameraResult, id: Crypto.randomUUID() }] + : [{ fileMetadata: cameraResult, id: Crypto.randomUUID() }], })); setIsPreparingFile(false); }; @@ -292,7 +296,9 @@ const CitizenForm = () => { setIsOptionsSheetOpen(false); setAttachments((prevAttachments) => ({ ...prevAttachments, - [questionId]: prevAttachments[questionId] ? [...prevAttachments[questionId], { fileMetadata, id: Crypto.randomUUID() }] : [{ fileMetadata, id: Crypto.randomUUID() }], + [questionId]: prevAttachments[questionId] + ? [...prevAttachments[questionId], { fileMetadata, id: Crypto.randomUUID() }] + : [{ fileMetadata, id: Crypto.randomUUID() }], })); setIsPreparingFile(false); } else { @@ -349,7 +355,7 @@ const CitizenForm = () => { /> {attachments[questionId]?.length ? ( - + {t("attachments.heading")} {attachments[questionId].map((attachment) => { @@ -419,8 +425,8 @@ const CitizenForm = () => { )} {isOptionsSheetOpen && ( - - {false || isPreparingFile ? ( + + {isPreparingFile ? ( ) : ( diff --git a/mobile/assets/locales/ro/translations.json b/mobile/assets/locales/ro/translations.json index f5b237c8c..392d592cc 100644 --- a/mobile/assets/locales/ro/translations.json +++ b/mobile/assets/locales/ro/translations.json @@ -120,6 +120,28 @@ "discard": "Întoarce-te", "save": "Rămâi aici" } + }, + "attachments": { + "heading": "Fișiere media încărcate", + "loading": "Se adaugă atașamentul... ", + "error": "Eroare la încărcarea atașamentului.", + "add": "Adaugă notițe sau fișiere media", + "menu": { + "add_note": "Adaugă o notiță", + "load": "Încarcă din galerie", + "take_picture": "Fă o poză", + "record_video": "Înregistrează un video", + "upload_audio": "Încarcă un fișier audio" + }, + "upload": { + "compressing": "Se comprimă atașamentul: ", + "preparing": "Se pregătește atașamentul...", + "starting": "Se începe încărcarea atașamentelor...", + "progress": "Progresul încărcării:", + "completed": "Încărcarea a fost finalizată.", + "aborted": "Încărcarea a fost anulată.", + "offline": "Ai nevoie de conexiune la internet pentru a face acestă acțiune." + } } }, "login": { diff --git a/mobile/components/ReviewCitizenFormSheet.tsx b/mobile/components/ReviewCitizenFormSheet.tsx index 1184c7262..7aa5c2594 100644 --- a/mobile/components/ReviewCitizenFormSheet.tsx +++ b/mobile/components/ReviewCitizenFormSheet.tsx @@ -1,4 +1,4 @@ -import React, { Dispatch, SetStateAction, useMemo } from "react"; +import React, { Dispatch, SetStateAction, useMemo, useState } from "react"; import { Icon } from "./Icon"; import { Typography } from "./Typography"; import Button from "./Button"; @@ -19,6 +19,14 @@ import { useCitizenUserData } from "../contexts/citizen-user/CitizenUserContext. import { useRouter } from "expo-router"; import Toast from "react-native-toast-message"; import { FileMetadata } from "../hooks/useCamera"; +import { MULTIPART_FILE_UPLOAD_SIZE } from "../common/constants"; +import { useUploadAttachmentCitizenMutation } from "../services/mutations/citizen/add-attachment-citizen.mutation"; +import { addAttachmentCitizenMultipartAbort, addAttachmentCitizenMultipartComplete, AddAttachmentCitizenStartAPIPayload } from "../services/api/citizen/attachments.api"; +import * as FileSystem from "expo-file-system"; +import { Buffer } from "buffer"; +import { uploadS3Chunk } from "../services/api/add-attachment.api"; +import * as Sentry from "@sentry/react-native"; +import MediaLoading from "./MediaLoading"; const LoadingScreen = () => { const { t } = useTranslation("citizen_form"); @@ -57,6 +65,11 @@ export default function ReviewCitizenFormSheet({ const { mutate: postCitizenForm, isPending } = usePostCitizenFormMutation(); const { selectedElectionRound } = useCitizenUserData(); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); + + const { mutateAsync: addAttachmentCitizen } = useUploadAttachmentCitizenMutation(); + const mappedAnswers = useMemo(() => { if (!answers || !questions) return {}; @@ -124,27 +137,30 @@ export default function ReviewCitizenFormSheet({ return currentForm.questions.filter((question) => mappedAnswers[question.id]); }, [currentForm?.questions, mappedAnswers]); - const handleSubmit = () => { + const handleSubmit = async () => { if (!currentForm || !selectedElectionRound || !answers) { console.log("⛔️ Missing data for sending review citizen form. ⛔️"); return; } + const citizenReportId = Crypto.randomUUID(); + postCitizenForm( { electionRoundId: selectedElectionRound, - citizenReportId: Crypto.randomUUID(), + citizenReportId, formId: currentForm.id, locationId: selectedLocationId, answers: Object.values(answers).filter(Boolean) as ApiFormAnswer[], }, { - onSuccess: (response) => { - console.log("🔵 [CitizenForm] form submitted successfully, redirect to success page"); + onSuccess: async (response) => { + await uploadAttachments(citizenReportId); router.replace(`/citizen/main/form/success?submissionId=${response.id}`); + setIsUploading(false); }, onError: (error) => { - console.log("🔴 [CitizenForm] error submitting form", error); + Sentry.captureException(error); // close review modal and display error toast setIsReviewSheetOpen(false); return Toast.show({ @@ -157,59 +173,170 @@ export default function ReviewCitizenFormSheet({ ); }; + const uploadAttachments = async (citizenReportId: string) => { + if (!currentForm || !selectedElectionRound || !answers) { + return; + } + + if (attachments && Object.keys(attachments).length > 0) { + setIsUploading(true); + const attachmentArray: { questionId: string, fileMetadata: FileMetadata, id: string }[] = Object.entries(attachments).map(([questionId, attachments]) => attachments.map(a => ({ ...a, questionId }))).flat() + try { + const totalParts = attachmentArray.reduce((acc, attachment) => { + return acc + Math.ceil(attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE); + }, 0); + let uploadedPartsNo = 0; + // Upload each attachment + setUploadProgress(`${t("attachments.upload.starting")}`); + for (const [, attachment] of attachmentArray.entries()) { + const payload: AddAttachmentCitizenStartAPIPayload = { + id: attachment.id, + fileName: attachment.fileMetadata.name, + contentType: attachment.fileMetadata.type, + numberOfUploadParts: Math.ceil( + attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE, + ), + electionRoundId: selectedElectionRound, + citizenReportId, + formId: currentForm.id, + questionId: attachment.questionId, + }; + + const data = await addAttachmentCitizen(payload); + await handleChunkUpload( + attachment.fileMetadata.uri, + data.uploadUrls, + data.uploadId, + attachment.id, + citizenReportId, + currentForm.id, + attachment.questionId, + uploadedPartsNo, + totalParts, + ); + uploadedPartsNo += payload.numberOfUploadParts; + } + setUploadProgress(t("attachments.upload.completed")); + } catch (err) { + Sentry.captureException(err); + setIsUploading(false); + } + } + + } + + const handleChunkUpload = async ( + filePath: string, + uploadUrls: Record, + uploadId: string, + attachmentId: string, + citizenReportId: string, + formId: string, + questionId: string, + uploadedPartsNo: number, + totalParts: number, + ) => { + try { + let etags: Record = {}; + const urls = Object.values(uploadUrls); + for (const [index, url] of urls.entries()) { + const chunk = await FileSystem.readAsStringAsync(filePath, { + length: MULTIPART_FILE_UPLOAD_SIZE, + position: index * MULTIPART_FILE_UPLOAD_SIZE, + encoding: FileSystem.EncodingType.Base64, + }); + const buffer = Buffer.from(chunk, "base64"); + const data = await uploadS3Chunk(url, buffer); + setUploadProgress( + `${t("attachments.upload.progress")} ${Math.ceil(((uploadedPartsNo + index) / totalParts) * 100)}%`, + ); + etags = { ...etags, [index + 1]: data.ETag }; + } + + if (selectedElectionRound) { + await addAttachmentCitizenMultipartComplete({ + uploadId, + etags, + electionRoundId: selectedElectionRound, + id: attachmentId, + citizenReportId, + }); + } + } catch (err) { + Sentry.captureException(err, { data: { selectedElectionRound, citizenReportId, formId, questionId } }); + if (selectedElectionRound) { + setUploadProgress(t("attachments.upload.aborted")); + await addAttachmentCitizenMultipartAbort({ + id: attachmentId, + uploadId, + electionRoundId: selectedElectionRound, + citizenReportId, + }); + } + } + }; + return ( { if (!open) { setIsReviewSheetOpen(false); } }} + > - - - {isPending ? ( - - ) : ( - - {t("review.heading")} - {displayedQuestions.map((question) => ( - - {question.text[language]} - {mappedAnswers && mappedAnswers[question.id] && ( - - {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} - - )} - {attachments && attachments[question.id] ? ( - - {t("attachments.heading")}: {attachments[question.id].length} - - ) : ( - false - )} - + {isUploading ? + ( + + ) : ( + <> + + + {isPending ? ( + + ) : ( + + {t("review.heading")} + {displayedQuestions.map((question) => ( + + {question.text[language]} + {mappedAnswers && mappedAnswers[question.id] && ( + + {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} + + )} + {attachments && attachments[question.id] ? ( + + {t("attachments.heading")}: {attachments[question.id].length} + + ) : ( + false + )} + + + ))} + + )} + + + - ))} - - )} - - - - - + + )} ); diff --git a/mobile/services/api/citizen/attachments.api.ts b/mobile/services/api/citizen/attachments.api.ts new file mode 100644 index 000000000..aae5eca3c --- /dev/null +++ b/mobile/services/api/citizen/attachments.api.ts @@ -0,0 +1,82 @@ +import API from "../../api"; + +export type AddAttachmentCitizenStartAPIPayload = { + id: string; + electionRoundId: string; + citizenReportId: string; + formId: string; + questionId: string; + fileName: string; + contentType: string; + numberOfUploadParts: number; +}; + +export type AddAttachmentCitizenCompleteAPIPayload = { + uploadId: string; + id: string; + etags: Record; + citizenReportId: string; + electionRoundId: string; +}; + +export type AddAttachmentCitizenAbortAPIPayload = { + uploadId: string; + id: string; + citizenReportId: string; + electionRoundId: string; +}; + +// Multipart Upload - Add Attachment - Citizen Report +export const addAttachmentCitizenMultipartStart = ({ + electionRoundId, + id, + citizenReportId, + formId, + questionId, + fileName, + contentType, + numberOfUploadParts, +}: AddAttachmentCitizenStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { + return API.post( + `election-rounds/${electionRoundId}/citizen-reports/${citizenReportId}/attachments:init`, + { + id, + formId, + questionId, + fileName, + contentType, + numberOfUploadParts, + }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentCitizenMultipartComplete = async ({ + uploadId, + id, + etags, + citizenReportId, + electionRoundId, +}: AddAttachmentCitizenCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/citizen-reports/${citizenReportId}/attachments/${id}:complete`, + { uploadId, etags }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentCitizenMultipartAbort = async ({ + uploadId, + id, + electionRoundId, + citizenReportId, +}: AddAttachmentCitizenAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/citizen-reports/${citizenReportId}/attachments/${id}:abort`, + { uploadId }, + {}, + ).then((res) => res.data); +}; diff --git a/mobile/services/mutations/citizen/add-attachment-citizen.mutation.ts b/mobile/services/mutations/citizen/add-attachment-citizen.mutation.ts new file mode 100644 index 000000000..a571ef3d9 --- /dev/null +++ b/mobile/services/mutations/citizen/add-attachment-citizen.mutation.ts @@ -0,0 +1,20 @@ +import { useMutation } from "@tanstack/react-query"; +import { + addAttachmentCitizenMultipartStart, + AddAttachmentCitizenStartAPIPayload, +} from "../../api/citizen/attachments.api"; +import { citizenQueryKeys } from "../../queries/citizen.query"; +import * as Sentry from "@sentry/react-native"; + +// Multipart Upload - Start +export const useUploadAttachmentCitizenMutation = () => { + return useMutation({ + mutationKey: citizenQueryKeys.attachments(), + mutationFn: (payload: AddAttachmentCitizenStartAPIPayload) => + addAttachmentCitizenMultipartStart(payload), + onError: (err, _variables, _context) => { + Sentry.captureException(err, { data: { _variables, _context } }); + }, + onSettled: (_data, _err, _variables) => {}, + }); +}; diff --git a/mobile/services/queries/citizen.query.ts b/mobile/services/queries/citizen.query.ts index e06c5abb8..428c923ad 100644 --- a/mobile/services/queries/citizen.query.ts +++ b/mobile/services/queries/citizen.query.ts @@ -13,6 +13,7 @@ export const citizenQueryKeys = { electionRounds: () => [...citizenQueryKeys.all, "election-rounds"] as const, reportingForms: (electionRoundId: string) => [...citizenQueryKeys.all, "reporting-forms", electionRoundId] as const, + attachments: () => [...citizenQueryKeys.all, "attachments", "start"] as const, locations: (electionRoundId: string) => [...citizenQueryKeys.all, "locations", electionRoundId] as const, locationsByParentId: (parentId: number, electionRoundId: string) => From 8be9684e1656a30a52baff8f071caf0e4861585d Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Thu, 17 Oct 2024 13:35:42 +0300 Subject: [PATCH 3/4] fix: [upload-v2] add offline check --- mobile/app/citizen/main/form/index.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/mobile/app/citizen/main/form/index.tsx b/mobile/app/citizen/main/form/index.tsx index 95e40dd87..456dbca40 100644 --- a/mobile/app/citizen/main/form/index.tsx +++ b/mobile/app/citizen/main/form/index.tsx @@ -307,6 +307,18 @@ const CitizenForm = () => { setIsPreparingFile(false); }; + const handleOnShowAttachementSheet = () => { + if (isOnline) { + setIsOptionsSheetOpen(true); + } else { + Toast.show({ + type: "error", + text2: t("attachments.upload.offline"), + visibilityTime: 5000, + text2Style: { textAlign: "center" }, + }); + } + }; if (isLoadingCurrentForm || !activeQuestion) { return Loading...; @@ -390,10 +402,7 @@ const CitizenForm = () => { { - Keyboard.dismiss(); - setIsOptionsSheetOpen(true); - }} + onPress={handleOnShowAttachementSheet} /> From 7c59f025fdc9b5049693ea226af8e0087c8ca69e Mon Sep 17 00:00:00 2001 From: luciatugui Date: Thu, 17 Oct 2024 15:05:21 +0300 Subject: [PATCH 4/4] feat: preview img, handle go back when attachments present, delete conf modal --- mobile/app/citizen/main/form/index.tsx | 43 ++++++++++++++++++++-- mobile/assets/locales/en/translations.json | 8 ++++ mobile/assets/locales/ro/translations.json | 12 +++++- mobile/components/MediaDialog.tsx | 14 ++++++- mobile/components/WarningDialog.tsx | 14 ++++++- 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/mobile/app/citizen/main/form/index.tsx b/mobile/app/citizen/main/form/index.tsx index 456dbca40..ee1b345fd 100644 --- a/mobile/app/citizen/main/form/index.tsx +++ b/mobile/app/citizen/main/form/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Card, ScrollView, XStack, YStack } from "tamagui"; +import { ScrollView, XStack, YStack } from "tamagui"; import { Screen } from "../../../../components/Screen"; import Header from "../../../../components/Header"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -32,6 +32,9 @@ import MediaLoading from "../../../../components/MediaLoading"; import { FileMetadata, useCamera } from "../../../../hooks/useCamera"; import * as DocumentPicker from "expo-document-picker"; import * as Crypto from "expo-crypto"; +import Card from "../../../../components/Card"; +import { AttachmentMimeType } from "../../../../services/api/get-attachments.api"; +import { MediaDialog } from "../../../../components/MediaDialog"; // import { Buffer } from "buffer"; // import * as FileSystem from "expo-file-system"; @@ -83,6 +86,11 @@ const CitizenForm = () => { const [questionId, setQuestionId] = useState(initialQuestionId); const [isReviewSheetOpen, setIsReviewSheetOpen] = useState(false); const [showWarningDialog, setShowWarningDialog] = useState(false); + const [selectedAttachmentToDelete, setSelectedAttachmentToDelete] = useState(null); + const [previewAttachment, setPreviewAttachment] = useState<{ + fileMetadata: FileMetadata; + id: string; + } | null>(null); const { data: currentForm, @@ -130,7 +138,7 @@ const CitizenForm = () => { }); const handleGoBack = () => { - if (isDirty) { + if (isDirty || Object.keys(attachments).length) { Keyboard.dismiss(); setShowWarningDialog(true); } else { @@ -308,6 +316,7 @@ const CitizenForm = () => { setIsPreparingFile(false); }; const handleOnShowAttachementSheet = () => { + Keyboard.dismiss(); if (isOnline) { setIsOptionsSheetOpen(true); } else { @@ -379,13 +388,17 @@ const CitizenForm = () => { flexDirection="row" justifyContent="space-between" alignItems="center" + onPress={() => setPreviewAttachment(attachment)} > {attachment.fileMetadata.name} { + Keyboard.dismiss(); + setSelectedAttachmentToDelete(attachment.id); + }} pressStyle={{ opacity: 0.5 }} > @@ -399,6 +412,30 @@ const CitizenForm = () => { false )} + {selectedAttachmentToDelete && ( + { + removeAttachmentLocal(selectedAttachmentToDelete); + setSelectedAttachmentToDelete(null); + }} + onCancel={() => setSelectedAttachmentToDelete(null)} + /> + )} + + {previewAttachment && previewAttachment.fileMetadata.type === AttachmentMimeType.IMG && ( + setPreviewAttachment(null)} + /> + )} + = ({ trigger, media, onClos const { t } = useTranslation("common"); const [isLoaded, setIsLoaded] = useState(false); + const handleHardwareBackPress = () => { + onClose(); + return true; + }; + + useEffect(() => { + const backHandler = BackHandler.addEventListener("hardwareBackPress", handleHardwareBackPress); + + return () => backHandler.remove(); + }, []); + return ( {trigger && {trigger}} diff --git a/mobile/components/WarningDialog.tsx b/mobile/components/WarningDialog.tsx index c27d2fe30..7a952ab28 100644 --- a/mobile/components/WarningDialog.tsx +++ b/mobile/components/WarningDialog.tsx @@ -3,7 +3,7 @@ import { Typography } from "./Typography"; import { Dialog } from "./Dialog"; import { ScrollView, useTheme, XStack, YStack } from "tamagui"; import Button from "./Button"; -import { StyleProp, TextStyle } from "react-native"; +import { BackHandler, StyleProp, TextStyle } from "react-native"; type WarningDialogProps = { title: string; @@ -27,6 +27,18 @@ const WarningDialog = ({ theme = "danger", }: WarningDialogProps) => { const tamaguiTheme = useTheme(); + + const handleHardwareBackPress = () => { + onCancel(); + return true; + }; + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener("hardwareBackPress", handleHardwareBackPress); + + return () => backHandler.remove(); + }, []); + return (