From 325bfbe19fadaaeff3b05600641d982745b361c6 Mon Sep 17 00:00:00 2001 From: leonardo Date: Mon, 12 Jan 2026 12:42:33 +0100 Subject: [PATCH] feat: Enhance document management with a first draft of upload and download functionalities (needs revision) --- app/(protected)/profile/documents.tsx | 49 +++++---- app/(protected)/sites/[id].tsx | 46 +++++++- components/AddDocumentModal.tsx | 8 +- package-lock.json | 72 ++++++++----- package.json | 4 +- types/types.ts | 1 + utils/documentUtils.tsx | 149 ++++++++++++++++++++++++++ 7 files changed, 276 insertions(+), 53 deletions(-) create mode 100644 utils/documentUtils.tsx diff --git a/app/(protected)/profile/documents.tsx b/app/(protected)/profile/documents.tsx index 2982115..cd7f46e 100644 --- a/app/(protected)/profile/documents.tsx +++ b/app/(protected)/profile/documents.tsx @@ -1,5 +1,6 @@ -import { Download, FileText, MapPin, Plus, Search, CalendarIcon } from 'lucide-react-native'; +import { ArrowLeft, Download, FileText, MapPin, Plus, Search, CalendarIcon } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; +import { useRouter } from 'expo-router'; import { RangePickerModal } from '@/components/RangePickerModal'; import { Alert, RefreshControl, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { DocumentItem } from '@/types/types'; @@ -8,8 +9,10 @@ import dayjs from 'dayjs'; import LoadingScreen from '@/components/LoadingScreen'; import { formatTimestamp, parseTimestamp } from '@/utils/dateTime'; import AddDocumentModal from '@/components/AddDocumentModal'; +import { downloadAndShareDocument, downloadDocumentByUrl, downloadDocumentLegacy, uploadDocument } from '@/utils/documentUtils'; export default function DocumentsScreen() { + const router = useRouter(); const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -77,25 +80,9 @@ export default function DocumentsScreen() { // Gestione Caricamento Documento const handleUploadDocument = async (file: any, customTitle?: string) => { - console.log('Caricamento documento:', file, 'con titolo personalizzato:', customTitle); setIsUploading(true); try { - // const formData = new FormData(); - // formData.append('file', { - // uri: file.uri, - // name: file.name, - // type: file.mimeType || 'application/octet-stream', - // } as any); - // if (customTitle) { - // formData.append('title', customTitle); - // } - - // const response = await api.post('/attachment/upload', formData, { - // headers: { - // 'Content-Type': 'multipart/form-data', - // }, - // }); - + const response = await uploadDocument(file, null, customTitle); // console.log('Risposta caricamento:', response.data); // Alert.alert('Successo', 'Documento caricato con successo!'); // setShowUploadModal(false); @@ -108,6 +95,16 @@ export default function DocumentsScreen() { } } + // Gestione Download e Condivisione Documento + const handleDownloadAndShare = async (mimetype: string, fileName: string, fileUrl: string) => { + try { + await downloadAndShareDocument(mimetype, fileName, fileUrl); + } catch (error) { + console.error('Errore nel download/condivisione del documento:', error); + Alert.alert('Errore', 'Impossibile scaricare/condividere il documento. Riprova più tardi.'); + } + }; + if (isLoading && !refreshing) { return ( @@ -117,10 +114,14 @@ export default function DocumentsScreen() { return ( {/* Header */} - {/* TODO: Aggiungi torna indietro */} - - Documenti - Gestisci i tuoi documenti + + router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100"> + + + + Documenti + Gestisci i tuoi documenti + @@ -179,7 +180,9 @@ export default function DocumentsScreen() { - + downloadDocumentLegacy(doc.mimetype, doc.title, doc.url)} // downloadDocumentByUrl(doc.url, doc.title) handleDownloadAndShare(doc.mimetype, doc.title, doc.url) + className="p-4 bg-gray-50 rounded-2xl active:bg-gray-100"> diff --git a/app/(protected)/sites/[id].tsx b/app/(protected)/sites/[id].tsx index 40e0f9b..1e951c3 100644 --- a/app/(protected)/sites/[id].tsx +++ b/app/(protected)/sites/[id].tsx @@ -8,6 +8,8 @@ import LoadingScreen from '@/components/LoadingScreen'; import api from '@/utils/api'; import dayjs from 'dayjs'; import { formatTimestamp, parseTimestamp } from '@/utils/dateTime'; +import { downloadAndShareDocument, uploadDocument } from '@/utils/documentUtils'; +import AddDocumentModal from '@/components/AddDocumentModal'; export default function SiteDocumentsScreen() { const router = useRouter(); @@ -17,6 +19,9 @@ export default function SiteDocumentsScreen() { const [isLoading, setIsLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + const [isUploading, setIsUploading] = useState(false); + // Fetch dei documenti del cantiere const fetchSiteDocuments = useCallback(async (siteId: number, isRefreshing = false) => { try { @@ -88,6 +93,33 @@ export default function SiteDocumentsScreen() { return true; }); + // Gestione Download e Condivisione Documento + const handleDownloadAndShare = async (mimetype: string, fileName: string, fileUrl: string) => { + try { + await downloadAndShareDocument(mimetype, fileName, fileUrl); + } catch (error) { + console.error('Errore nel download/condivisione del documento:', error); + Alert.alert('Errore', 'Impossibile scaricare il documento. Riprova più tardi.'); + } + }; + + // Gestione Caricamento Documento + const handleUploadDocument = async (file: any, customTitle?: string) => { + setIsUploading(true); + try { + const response = await uploadDocument(file, Number(params.id), customTitle); + // console.log('Risposta caricamento:', response.data); + // Alert.alert('Successo', 'Documento caricato con successo!'); + // setShowUploadModal(false); + // fetchSiteDocuments(Number(params.id)); // Ricarica la lista dei documenti + } catch (error) { + console.error('Errore nel caricamento del documento:', error); + Alert.alert('Errore', 'Impossibile caricare il documento. Riprova più tardi.'); + } finally { + setIsUploading(false); + } + } + if (!site || (isLoading && !refreshing)) { return ( @@ -175,7 +207,9 @@ export default function SiteDocumentsScreen() { {formatTimestamp(doc.updated_at)} - + handleDownloadAndShare(doc.mimetype, doc.title, doc.url)} + className="p-4 bg-gray-50 rounded-2xl active:bg-gray-100"> @@ -189,11 +223,19 @@ export default function SiteDocumentsScreen() { {/* FAB (Spostato qui) */} alert(`Aggiungi doc a ${site.name}`)} + onPress={() => setShowUploadModal(true)} className="absolute bottom-8 right-6 w-16 h-16 bg-[#099499] rounded-full shadow-lg items-center justify-center active:scale-90" > + + {/* Modale Caricamento Documento */} + setShowUploadModal(false)} + onUpload={handleUploadDocument} + isUploading={isUploading} + /> ); } \ No newline at end of file diff --git a/components/AddDocumentModal.tsx b/components/AddDocumentModal.tsx index 9c09a2a..00a4a0b 100644 --- a/components/AddDocumentModal.tsx +++ b/components/AddDocumentModal.tsx @@ -22,6 +22,7 @@ export default function AddDocumentModal({ visible, onClose, onUpload, isUploadi } }, [visible]); + // TODO: Considerare selezione multipla? const pickDocument = async () => { try { const result = await DocumentPicker.getDocumentAsync({ @@ -34,7 +35,7 @@ export default function AddDocumentModal({ visible, onClose, onUpload, isUploadi const asset = result.assets[0]; setSelectedFile(asset); - // Pre-compila il titolo con il nome del file (senza estensione se vuoi essere fancy, qui lo lascio intero) + // Pre-compila il titolo con il nome del file setCustomTitle(asset.name); } catch (err) { @@ -54,6 +55,7 @@ export default function AddDocumentModal({ visible, onClose, onUpload, isUploadi }; // Formatta dimensione file + // TODO: Spostare in utils? const formatSize = (size?: number) => { if (!size) return '0 B'; const k = 1024; @@ -102,10 +104,10 @@ export default function AddDocumentModal({ visible, onClose, onUpload, isUploadi - + {selectedFile.name} - + {formatSize(selectedFile.size)} diff --git a/package-lock.json b/package-lock.json index 0a9aa1d..15cd737 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "expo": "~54.0.25", "expo-constants": "~18.0.10", "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-image": "~3.0.10", - "expo-linking": "~8.0.9", + "expo-linking": "~8.0.11", "expo-router": "~6.0.15", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.11", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", @@ -1756,15 +1758,15 @@ } }, "node_modules/@expo/config": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.11.tgz", - "integrity": "sha512-bGKNCbHirwgFlcOJHXpsAStQvM0nU3cmiobK0o07UkTfcUxl9q9lOQQh2eoMGqpm6Vs1IcwBpYye6thC3Nri/w==", + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", + "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.3", - "@expo/config-types": "^54.0.9", - "@expo/json-file": "^10.0.7", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1777,14 +1779,14 @@ } }, "node_modules/@expo/config-plugins": { - "version": "54.0.3", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.3.tgz", - "integrity": "sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw==", + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", "license": "MIT", "dependencies": { - "@expo/config-types": "^54.0.9", - "@expo/json-file": "~10.0.7", - "@expo/plist": "^0.4.7", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1811,9 +1813,9 @@ } }, "node_modules/@expo/config-types": { - "version": "54.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.9.tgz", - "integrity": "sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw==", + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", "license": "MIT" }, "node_modules/@expo/config/node_modules/@babel/code-frame": { @@ -6453,9 +6455,9 @@ } }, "node_modules/expo-file-system": { - "version": "19.0.20", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.20.tgz", - "integrity": "sha512-Jr/nNvJmUlptS3cHLKVBNyTyGMHNyxYBKRph1KRe0Nb3RzZza1gZLZXMG5Ky//sO2azTn+OaT0dv/lAyL0vJNA==", + "version": "19.0.21", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", + "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6514,13 +6516,12 @@ } }, "node_modules/expo-linking": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.10.tgz", - "integrity": "sha512-0EKtn4Sk6OYmb/5ZqK8riO0k1Ic+wyT3xExbmDvUYhT7p/cKqlVUExMuOIAt3Cx3KUUU1WCgGmdd493W/D5XjA==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", + "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "license": "MIT", - "peer": true, "dependencies": { - "expo-constants": "~18.0.11", + "expo-constants": "~18.0.12", "invariant": "^2.2.4" }, "peerDependencies": { @@ -6528,6 +6529,20 @@ "react-native": "*" } }, + "node_modules/expo-linking/node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.23", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz", @@ -6828,6 +6843,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.12", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.12.tgz", diff --git a/package.json b/package.json index 2d6d979..12cab9f 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,14 @@ "expo": "~54.0.25", "expo-constants": "~18.0.10", "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-image": "~3.0.10", - "expo-linking": "~8.0.9", + "expo-linking": "~8.0.11", "expo-router": "~6.0.15", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.11", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", diff --git a/types/types.ts b/types/types.ts index 8706744..0553dfb 100644 --- a/types/types.ts +++ b/types/types.ts @@ -22,6 +22,7 @@ export interface AttendanceRecord { export interface DocumentItem { id: number; + mimetype: string; title: string; url: string; updated_at: string; diff --git a/utils/documentUtils.tsx b/utils/documentUtils.tsx new file mode 100644 index 0000000..19dfab3 --- /dev/null +++ b/utils/documentUtils.tsx @@ -0,0 +1,149 @@ +import api from '@/utils/api'; +import { Directory, File, Paths } from 'expo-file-system'; +import * as FileSystem from 'expo-file-system/legacy'; +import { StorageAccessFramework } from 'expo-file-system/legacy'; +import * as Sharing from 'expo-sharing'; +import * as Linking from 'expo-linking'; +import { Platform } from 'react-native'; + +/** + * Gestisce l'upload di un documento verso il server usando Expo FileSystem + * @param file File da caricare (deve avere almeno la proprietà 'uri') + * @param siteId ID del sito a cui associare il documento (null per registro generale) + * @param customTitle Titolo personalizzato per il documento (opzionale) + */ +export const uploadDocument = async ( + file: any, + siteId: number | null, + customTitle?: string +): Promise => { + if (!file || !file.uri) { + throw new Error("File non valido per l'upload."); + } + + if (siteId === null) { + console.log("Uploading document:", file, "to registry with title:", customTitle); + } else { + console.log("Uploading document:", file, "to site:", siteId, "with title:", customTitle); + } + + // TODO: Funzione di upload (manca lato backend) +}; + +/** + * Scarica un documento e offre di aprirlo/condividerlo (expo-sharing) + * @param attachmentId ID o URL relativo del documento + * @param fileName Nome con cui salvare il file + * @param fileUrl URL completo del file da scaricare + */ +export const downloadAndShareDocument = async ( + mimetype: string, + fileName: string, + fileUrl: string +): Promise => { + try { + // TODO: Gestire meglio il download (attualmente si basa su expo-sharing) + if (!fileUrl || !fileName) { + throw new Error("Parametri mancanti per il download del documento."); + } + + const destination = new Directory(Paths.cache, 'documents'); + destination.exists ? destination.delete() : null; + destination.create({ overwrite: true }); + + const tmpFile = await File.downloadFileAsync(fileUrl, destination); + console.log("File temporaneo scaricato in:", tmpFile.uri); + + const outFile = new File(destination, fileName); + await tmpFile.move(outFile); + console.log("File spostato in:", outFile.uri); + console.log("File type:", mimetype); + + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(outFile.uri, { + mimeType: mimetype, + dialogTitle: `Scarica ${fileName}`, + UTI: 'public.item' + }); + } else { + throw new Error("Condivisione non supportata su questo dispositivo."); + } + + } catch (error) { + console.error("Download Error:", error); + throw error; + } +}; + +// TODO: Test con versione legacy di FileSystem e SAF +export const downloadDocumentLegacy = async ( + mimetype: string, + fileName: string, + fileUrl: string +): Promise => { + try { + if (!fileUrl || !fileName) { + throw new Error("Parametri mancanti per il download del documento."); + } + + const path = FileSystem.cacheDirectory + 'documents/'; + // Download del file nella directory selezionata + const tmpFile = await FileSystem.downloadAsync(fileUrl, path + fileName); + console.log("File temporaneo scaricato in:", tmpFile.uri); + + if (Platform.OS === 'android') { + const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync(); + if (permission.granted) { + // Gets SAF URI from response + const safUri = permission.directoryUri; + console.log("Selected Directory URI:", safUri); + + const fileContent = await FileSystem.readAsStringAsync(tmpFile.uri, { encoding: FileSystem.EncodingType.Base64 }); + console.log("File letto in Base64, dimensione:", fileContent.length); + + // Copia il file nella directory SAF selezionata + const destUri = await StorageAccessFramework.createFileAsync( + safUri, + fileName, + mimetype + ); + console.log("Destinazione SAF URI:", destUri); + + await FileSystem.writeAsStringAsync(destUri, fileContent, { encoding: FileSystem.EncodingType.Base64 }); + console.log("File riscritto in SAF."); + } + } else if (Platform.OS === 'ios') { + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(tmpFile.uri, { + mimeType: mimetype, + dialogTitle: `Scarica ${fileName}`, + UTI: 'public.item' + }); + } else { + throw new Error("Condivisione non supportata su questo dispositivo."); + } + } + + } catch (error) { + console.error("Download Error:", error); + throw error; + } +}; + +// TODO: Test con Linking standard +export const downloadDocumentByUrl = async ( + fileUrl: string, + fileName: string +): Promise => { + try { + if (!fileUrl || !fileName) { + throw new Error("Parametri mancanti per il download del documento."); + } + + Linking.openURL(fileUrl); + + } catch (error) { + console.error("Download Error:", error); + throw error; + } +}; \ No newline at end of file