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 d61f00eae..ee1b345fd 100644 --- a/mobile/app/citizen/main/form/index.tsx +++ b/mobile/app/citizen/main/form/index.tsx @@ -26,6 +26,17 @@ 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 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"; const CitizenForm = () => { const { t } = useTranslation(["citizen_form", "network_banner"]); @@ -36,6 +47,15 @@ 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< + Record + >({}); + + const { uploadCameraOrMedia } = useCamera(); + if (!selectedElectionRound) { return ( @@ -66,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, @@ -113,7 +138,7 @@ const CitizenForm = () => { }); const handleGoBack = () => { - if (isDirty) { + if (isDirty || Object.keys(attachments).length) { Keyboard.dismiss(); setShowWarningDialog(true); } else { @@ -227,6 +252,83 @@ 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); + }; + const handleOnShowAttachementSheet = () => { + Keyboard.dismiss(); + if (isOnline) { + setIsOptionsSheetOpen(true); + } else { + Toast.show({ + type: "error", + text2: t("attachments.upload.offline"), + visibilityTime: 5000, + text2Style: { textAlign: "center" }, + }); + } + }; + if (isLoadingCurrentForm || !activeQuestion) { return Loading...; } @@ -273,24 +375,72 @@ const CitizenForm = () => { required={true} /> - {/* attachments */} - {/* {activeElectionRound?.id && selectedPollingStation?.pollingStationId && formId && ( - + {t("attachments.heading")} + + {attachments[questionId].map((attachment) => { + return ( + setPreviewAttachment(attachment)} + > + + {attachment.fileMetadata.name} + + { + Keyboard.dismiss(); + setSelectedAttachmentToDelete(attachment.id); + }} + pressStyle={{ opacity: 0.5 }} + > + + + + ); + })} + + + ) : ( + false + )} + + {selectedAttachmentToDelete && ( + { + removeAttachmentLocal(selectedAttachmentToDelete); + setSelectedAttachmentToDelete(null); + }} + onCancel={() => setSelectedAttachmentToDelete(null)} + /> + )} + + {previewAttachment && previewAttachment.fileMetadata.type === AttachmentMimeType.IMG && ( + setPreviewAttachment(null)} /> - )} */} + )} - {/* { - Keyboard.dismiss(); - return setIsOptionsSheetOpen(true); - }} - /> */} + onPress={handleOnShowAttachementSheet} + /> { currentForm={currentForm} answers={answers} questions={currentForm?.questions} + attachments={attachments} setIsReviewSheetOpen={setIsReviewSheetOpen} selectedLocationId={selectedLocationId} /> )} + {isOptionsSheetOpen && ( + + {isPreparingFile ? ( + + ) : ( + + + {t("attachments.menu.load")} + + + {t("attachments.menu.take_picture")} + + + {t("attachments.menu.upload_audio")} + + + )} + + )} + {showWarningDialog && ( = ({ 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/ReviewCitizenFormSheet.tsx b/mobile/components/ReviewCitizenFormSheet.tsx index 8f421d8d0..bfee64a92 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"; @@ -9,7 +9,7 @@ import { MultiSelectAnswer, SingleSelectAnswer, } from "../services/interfaces/answer.type"; -import { Sheet, Spinner, YStack } from "tamagui"; +import { Separator, Sheet, Spinner, YStack } from "tamagui"; import { getAnswerDisplay } from "../common/utils/answers"; import { ApiFormQuestion } from "../services/interfaces/question.type"; import { useTranslation } from "react-i18next"; @@ -18,6 +18,15 @@ import { usePostCitizenFormMutation } from "../services/mutations/citizen/post-c import { useCitizenUserData } from "../contexts/citizen-user/CitizenUserContext.provider"; 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"); @@ -36,6 +45,7 @@ export default function ReviewCitizenFormSheet({ currentForm, answers, questions, + attachments, setIsReviewSheetOpen, selectedLocationId, language, @@ -43,6 +53,7 @@ export default function ReviewCitizenFormSheet({ currentForm: FormAPIModel | undefined; answers: Record | undefined; questions: ApiFormQuestion[] | undefined; + attachments: Record | undefined; setIsReviewSheetOpen: Dispatch>; selectedLocationId: string; language: string; @@ -54,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 {}; @@ -121,29 +137,34 @@ 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?formId=${currentForm.id}&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({ @@ -156,51 +177,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)} - - )} + {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/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 ( ; + 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) =>