From 0cc261105a03da983890984f4d012b2370ba017c Mon Sep 17 00:00:00 2001 From: m Date: Mon, 27 Jul 2026 15:52:51 +0200 Subject: [PATCH] load local files --- mobile/app.json | 13 +- mobile/app/file-detail.tsx | 99 +++++++++-- mobile/app/folder.tsx | 27 ++- mobile/app/index.tsx | 112 +++++++++++-- mobile/components/FileThumbnail.tsx | 13 +- mobile/components/SyncStatusBadge.tsx | 38 +++++ mobile/hooks/useDeviceFiles.ts | 93 ++++++++++ mobile/hooks/useLocalFiles.ts | 44 +++++ mobile/hooks/useUnifiedFiles.ts | 233 ++++++++++++++++++++++++++ mobile/package-lock.json | 11 ++ mobile/package.json | 1 + mobile/services/localFileRegistry.ts | 82 +++++++++ mobile/types/index.ts | 16 +- 13 files changed, 737 insertions(+), 45 deletions(-) create mode 100644 mobile/components/SyncStatusBadge.tsx create mode 100644 mobile/hooks/useDeviceFiles.ts create mode 100644 mobile/hooks/useLocalFiles.ts create mode 100644 mobile/hooks/useUnifiedFiles.ts create mode 100644 mobile/services/localFileRegistry.ts diff --git a/mobile/app.json b/mobile/app.json index e523c26..345999a 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -18,7 +18,9 @@ "permissions": [ "CAMERA", "READ_EXTERNAL_STORAGE", - "WRITE_EXTERNAL_STORAGE" + "WRITE_EXTERNAL_STORAGE", + "READ_MEDIA_IMAGES", + "READ_MEDIA_VIDEO" ], "adaptiveIcon": { "backgroundColor": "#E6F4FE", @@ -33,7 +35,14 @@ "favicon": "./assets/favicon.png" }, "plugins": [ - "expo-secure-store" + "expo-secure-store", + [ + "expo-media-library", + { + "photosPermission": "VaultDrop a besoin d'accéder à vos photos pour les afficher et les organiser.", + "videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser." + } + ] ] } } diff --git a/mobile/app/file-detail.tsx b/mobile/app/file-detail.tsx index 642f4d6..27000a8 100644 --- a/mobile/app/file-detail.tsx +++ b/mobile/app/file-detail.tsx @@ -10,14 +10,19 @@ import { ScrollView, Modal, Pressable, + TouchableOpacity, } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; import { RouteProp, useRoute } from '@react-navigation/native'; import { useFile, useFileImage } from '../hooks/useFiles'; +import { useDownloadFile } from '../hooks/useUnifiedFiles'; +import { localFileRegistry } from '../services/localFileRegistry'; import { TagChip } from '../components/TagChip'; import { FileThumbnail } from '../components/FileThumbnail'; +import { SyncStatusBadge } from '../components/SyncStatusBadge'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { ZoomableImage } from '../components/ZoomableImage'; -import type { Thumbnail } from '../types'; +import type { Thumbnail, SyncStatus } from '../types'; const SCREEN_WIDTH = Dimensions.get('window').width; @@ -34,15 +39,43 @@ type FileDetailRouteProp = RouteProp; function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (state: ModalState) => void }) { const { data: imageData, isLoading: imageLoading } = useFileImage(fileId); const { data: fileData } = useFile(fileId); + const downloadFile = useDownloadFile(); + const [downloading, setDownloading] = useState(false); + const uri = imageData?.data?.url; const file = fileData as any; + const localEntry = localFileRegistry.getByBackendId(fileId); + const syncStatus: SyncStatus = localEntry + ? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced') + : (uri ? 'synced' : 'cloud'); + const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? []) .filter((t: Thumbnail) => t.resolutionLabel === 'full') .sort((a: Thumbnail, b: Thumbnail) => a.pageNumber - b.pageNumber); const hasPages = fullThumbnails.length > 0; + const handleDownload = useCallback(async () => { + if (!file) return; + setDownloading(true); + try { + await downloadFile.mutateAsync({ + id: fileId, + backendFileId: fileId, + name: file.data?.name ?? fileId, + mimeType: file.mimeType ?? 'application/octet-stream', + size: file.data?.size ?? 0, + createdAt: file.createdAt ?? new Date().toISOString(), + syncStatus: 'cloud', + tags: file.data?.tags ?? [], + isFolder: false, + }); + } catch {} finally { + setDownloading(false); + } + }, [file, fileId, downloadFile]); + return ( {hasPages ? ( @@ -75,13 +108,31 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: ) : file ? ( - + + + {syncStatus === 'cloud' && ( + + {downloading ? ( + + ) : ( + + )} + + {downloading ? 'Téléchargement...' : 'Télécharger'} + + + )} + ) : ( )} @@ -89,7 +140,10 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: )} - {imageData?.data?.name ?? file?.name ?? fileId} + + {imageData?.data?.name ?? file?.name ?? fileId} + + {imageData?.data?.size != null && ( @@ -250,11 +304,36 @@ const styles = StyleSheet.create({ marginTop: -16, padding: 20, }, + detailHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, fileName: { fontSize: 18, fontWeight: '700', color: '#333', - marginBottom: 8, + flex: 1, + marginRight: 8, + }, + cloudOnlyContainer: { + alignItems: 'center', + gap: 16, + }, + downloadBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#1976D2', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + gap: 8, + }, + downloadBtnText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', }, meta: { fontSize: 14, diff --git a/mobile/app/folder.tsx b/mobile/app/folder.tsx index e143d1d..5ab84b2 100644 --- a/mobile/app/folder.tsx +++ b/mobile/app/folder.tsx @@ -3,8 +3,10 @@ import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, Alert } import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialIcons } from '@expo/vector-icons'; -import { useFilesByParent, useFileImage, useDeleteFile } from '../hooks/useFiles'; -import { FileItem, isFolder } from '../types'; +import { useDeleteFile, useFileImage } from '../hooks/useFiles'; +import { useUnifiedFilesByParent } from '../hooks/useUnifiedFiles'; +import { UnifiedFileItem } from '../hooks/useUnifiedFiles'; +import { isFolder } from '../types'; import { FileThumbnail } from '../components/FileThumbnail'; const NUM_COLUMNS = 3; @@ -21,7 +23,7 @@ type NavigationProp = NativeStackNavigationProp; type FolderRouteProp = RouteProp; function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: { - file: FileItem; + file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean; @@ -39,12 +41,13 @@ function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: activeOpacity={0.7} > {selected && ( @@ -62,7 +65,7 @@ export function FolderScreen() { const route = useRoute(); const navigation = useNavigation(); const { folderId, folderName } = route.params; - const { data, isLoading, error } = useFilesByParent(folderId); + const { data, isLoading } = useUnifiedFilesByParent(folderId); const deleteFile = useDeleteFile(); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -73,7 +76,7 @@ export function FolderScreen() { }, [navigation, folderName]); const files = useMemo(() => { - return data?.data ?? []; + return data ?? []; }, [data]); const fileIdToIndex = useMemo(() => { @@ -117,7 +120,7 @@ export function FolderScreen() { ); }, [selectedIds, deleteFile]); - const handleItemPress = useCallback((file: FileItem) => { + const handleItemPress = useCallback((file: UnifiedFileItem) => { if (selectionMode) { toggleSelection(file.id); } else if (isFolder(file)) { @@ -130,7 +133,7 @@ export function FolderScreen() { } }, [selectionMode, toggleSelection, navigation, files, fileIdToIndex]); - const handleItemLongPress = useCallback((file: FileItem) => { + const handleItemLongPress = useCallback((file: UnifiedFileItem) => { if (!selectionMode) toggleSelection(file.id); }, [selectionMode, toggleSelection]); @@ -142,14 +145,6 @@ export function FolderScreen() { ); } - if (error) { - return ( - - Erreur de chargement - - ); - } - return ( ; -type GroupedFiles = Record; +type GroupedFiles = Record; function parseBackendDate(dateStr: string): Date | null { if (!dateStr) return null; @@ -33,7 +35,7 @@ function parseBackendDate(dateStr: string): Date | null { return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6])); } -function groupByDate(files: FileItem[]): GroupedFiles { +function groupByDate(files: UnifiedFileItem[]): GroupedFiles { const groups: GroupedFiles = {}; for (const file of files) { const d = parseBackendDate(file.createdAt); @@ -47,7 +49,7 @@ function groupByDate(files: FileItem[]): GroupedFiles { return groups; } -function groupByTag(files: FileItem[]): GroupedFiles { +function groupByTag(files: UnifiedFileItem[]): GroupedFiles { const groups: GroupedFiles = {}; for (const file of files) { const tags = file.tags ?? []; @@ -76,7 +78,7 @@ function formatDateLabel(key: string): string { return key.charAt(0).toUpperCase() + key.slice(1); } -function FileGridItem({ file, onPress, onLongPress, selected }: { file: FileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) { +function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) { const { data, isLoading } = useFileImage(file.id); return ( @@ -88,12 +90,13 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: FileItem activeOpacity={0.7} > {selected && ( @@ -107,7 +110,7 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: FileItem ); } -function matchesQuery(file: FileItem, query: string, filters: SearchFilters): boolean { +function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean { if (!query) return true; const q = query.toLowerCase(); if (filters.name && file.name.toLowerCase().includes(q)) return true; @@ -130,8 +133,9 @@ function matchesQuery(file: FileItem, query: string, filters: SearchFilters): bo export function HomeScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { data, isLoading, error } = useFiles(); + const { data, isLoading, error, hasPermission, requestPermission } = useUnifiedFiles(); const deleteFile = useDeleteFile(); + const freeLocalSpace = useFreeLocalSpace(); const [searchQuery, setSearchQuery] = useState(''); const [filters, setFilters] = useState({ name: true, ocrText: true, tags: true }); const [keyboardOpen, setKeyboardOpen] = useState(false); @@ -153,15 +157,13 @@ export function HomeScreen() { const selectionMode = selectedIds.size > 0; - const files = data?.data ?? []; + const files = data ?? []; const filteredFiles = useMemo( () => searchQuery.trim() ? files.filter((f) => matchesQuery(f, searchQuery, filters)) : files, [files, searchQuery, filters] ); - console.log(filteredFiles.map(f => f.tags)) - const groups = groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles); const groupKeys = Object.keys(groups); @@ -208,6 +210,33 @@ export function HomeScreen() { ); }, [selectedIds, deleteFile]); + const handleFreeSpace = useCallback(() => { + const ids = Array.from(selectedIds); + const syncedIds = ids.filter((id) => { + const f = files.find((fi) => fi.id === id); + return f?.syncStatus === 'synced'; + }); + if (syncedIds.length === 0) { + Alert.alert('Info', 'Aucun fichier avec copie locale sélectionné.'); + return; + } + const label = syncedIds.length === 1 ? 'ce fichier' : `ces ${syncedIds.length} fichiers`; + Alert.alert( + 'Libérer l\'espace', + `${label} sera supprimé de votre appareil mais restera disponible sur le serveur. Vous pourrez le re-télécharger ultérieurement.`, + [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Libérer', + onPress: async () => { + await freeLocalSpace.mutateAsync(syncedIds); + setSelectedIds(new Set()); + }, + }, + ] + ); + }, [selectedIds, files, freeLocalSpace]); + const handleEdit = useCallback(() => { const ids = Array.from(selectedIds); if (ids.length === 0) return; @@ -240,7 +269,7 @@ export function HomeScreen() { setSelectedIds(new Set()); }, [selectedIds, moveFiles]); - const handleItemPress = useCallback((file: FileItem) => { + const handleItemPress = useCallback((file: UnifiedFileItem) => { if (selectionMode) { toggleSelection(file.id); } else if (isFolder(file)) { @@ -253,7 +282,7 @@ export function HomeScreen() { } }, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]); - const handleItemLongPress = useCallback((file: FileItem) => { + const handleItemLongPress = useCallback((file: UnifiedFileItem) => { if (!selectionMode) { toggleSelection(file.id); } @@ -262,7 +291,19 @@ export function HomeScreen() { if (isLoading) { return ( - Chargement... + {!hasPermission ? ( + + + + Autorisez l'accès à vos photos pour les afficher ici + + + Autoriser + + + ) : ( + Chargement... + )} ); } @@ -281,6 +322,17 @@ export function HomeScreen() { behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={Platform.OS === 'ios' ? 96 : 0} > + {!hasPermission && ( + + + + Autorisez l'accès à vos photos pour les afficher ici + + + Autoriser + + + )} item} @@ -386,6 +438,13 @@ export function HomeScreen() { Déplacer + + + Libérer + ) : ( @@ -478,6 +537,30 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#f5f5f5', }, + permissionBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#E3F2FD', + paddingHorizontal: 16, + paddingVertical: 14, + gap: 10, + }, + permissionText: { + flex: 1, + fontSize: 14, + color: '#333', + }, + permissionBtn: { + backgroundColor: '#1976D2', + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 8, + }, + permissionBtnText: { + fontSize: 14, + color: '#fff', + fontWeight: '600', + }, center: { flex: 1, justifyContent: 'center', @@ -625,6 +708,7 @@ const styles = StyleSheet.create({ tagChip: { backgroundColor: '#F3E5F5' }, folderChip: { backgroundColor: '#FFF3E0' }, moveChip: { backgroundColor: '#E0F2F1' }, + freeSpaceChip: { backgroundColor: '#FFF8E1' }, folderOption: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/components/FileThumbnail.tsx b/mobile/components/FileThumbnail.tsx index 55c6f3d..0d7ae1a 100644 --- a/mobile/components/FileThumbnail.tsx +++ b/mobile/components/FileThumbnail.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { View, Text, Image, ActivityIndicator, StyleSheet } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import type { ComponentProps } from 'react'; +import { SyncStatusBadge } from './SyncStatusBadge'; +import type { SyncStatus } from '../types'; type IconName = ComponentProps['name']; @@ -35,9 +37,10 @@ interface FileThumbnailProps { fileName: string; size: number; isLoading?: boolean; + syncStatus?: SyncStatus; } -export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading }: FileThumbnailProps) { +export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus }: FileThumbnailProps) { const info = getFileInfo(mimeType, fileName); const ext = getExtension(fileName); @@ -52,7 +55,12 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL const imageUri = thumbnailUrl || (uri && mimeType.startsWith('image/') ? uri : undefined); if (imageUri) { - return ; + return ( + + + {syncStatus && } + + ); } return ( @@ -61,6 +69,7 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL {ext.length <= 4 && ( {ext} )} + {syncStatus && } ); } diff --git a/mobile/components/SyncStatusBadge.tsx b/mobile/components/SyncStatusBadge.tsx new file mode 100644 index 0000000..7f9f36d --- /dev/null +++ b/mobile/components/SyncStatusBadge.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { SyncStatus } from '../types'; + +interface SyncStatusBadgeProps { + status: SyncStatus; + size?: number; +} + +const STATUS_CONFIG: Record = { + local: { icon: 'phone-android', color: '#757575', bg: 'rgba(245,245,245,0.9)' }, + synced: { icon: 'sync', color: '#4CAF50', bg: 'rgba(232,245,233,0.9)' }, + cloud: { icon: 'cloud', color: '#1976D2', bg: 'rgba(227,242,253,0.9)' }, +}; + +export function SyncStatusBadge({ status, size = 16 }: SyncStatusBadgeProps) { + const config = STATUS_CONFIG[status]; + const iconSize = Math.round(size * 0.7); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + badge: { + position: 'absolute', + top: 2, + right: 2, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 0.5, + borderColor: 'rgba(0,0,0,0.1)', + }, +}); diff --git a/mobile/hooks/useDeviceFiles.ts b/mobile/hooks/useDeviceFiles.ts new file mode 100644 index 0000000..92c4a15 --- /dev/null +++ b/mobile/hooks/useDeviceFiles.ts @@ -0,0 +1,93 @@ +import { useState, useEffect, useCallback } from 'react'; +import * as MediaLibrary from 'expo-media-library/legacy'; +import { LocalFileEntry } from '../types'; + +export interface DeviceFile { + id: string; + uri: string; + name: string; + mimeType: string; + size: number; + createdAt: string; +} + +export function useDeviceFiles() { + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasPermission, setHasPermission] = useState(false); + + const requestPermission = useCallback(async () => { + const req = await MediaLibrary.requestPermissionsAsync(); + console.log('[useDeviceFiles] requestPermission status:', req.status, 'granted:', req.granted, 'canAskAgain:', req.canAskAgain); + if (req.granted) { + setHasPermission(true); + return true; + } + const check = await MediaLibrary.getPermissionsAsync(); + console.log('[useDeviceFiles] getPermissionsAsync status:', check.status, 'granted:', check.granted); + const granted = check.granted; + setHasPermission(granted); + return granted; + }, []); + + const loadAssets = useCallback(async () => { + console.log('[useDeviceFiles] loadAssets called'); + setIsLoading(true); + try { + const result = await MediaLibrary.getAssetsAsync({ + first: 500, + mediaType: ['photo', 'video'], + sortBy: 'creationTime', + }); + + const deviceFiles: DeviceFile[] = result.assets.map((asset) => ({ + id: asset.id, + uri: asset.uri, + name: asset.filename, + mimeType: asset.mediaType === 'video' ? 'video/mp4' : 'image/jpeg', + size: 0, + createdAt: asset.creationTime + ? new Date(asset.creationTime).toISOString() + : new Date().toISOString(), + })); + + setFiles(deviceFiles); + console.log('[useDeviceFiles] loaded', deviceFiles.length, 'files'); + } catch (err) { + console.error('[useDeviceFiles] scan error:', err); + setFiles([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + requestPermission().then((granted) => { + console.log('[useDeviceFiles] mount permission result:', granted); + if (granted) { + loadAssets(); + } + }); + }, []); + + const rescan = useCallback(async () => { + const granted = await requestPermission(); + if (granted) { + await loadAssets(); + } + }, [requestPermission, loadAssets]); + + return { files, isLoading, hasPermission, requestPermission, rescan }; +} + +export function deviceFileToLocalEntry(deviceFile: DeviceFile): LocalFileEntry { + return { + id: deviceFile.id, + localUri: deviceFile.uri, + name: deviceFile.name, + mimeType: deviceFile.mimeType, + size: deviceFile.size, + syncStatus: 'local', + createdAt: deviceFile.createdAt, + }; +} diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts new file mode 100644 index 0000000..45dd867 --- /dev/null +++ b/mobile/hooks/useLocalFiles.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { useDeviceFiles } from './useDeviceFiles'; +import { localFileRegistry } from '../services/localFileRegistry'; +import { LocalFileEntry } from '../types'; + +export function useLocalFiles() { + const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan } = useDeviceFiles(); + + const registryEntries = useMemo(() => localFileRegistry.getAll(), []); + + const localFiles = useMemo(() => { + const merged = new Map(); + + for (const entry of registryEntries) { + merged.set(entry.id, entry); + } + + for (const df of deviceFiles) { + if (!merged.has(df.id)) { + merged.set(df.id, { + id: df.id, + localUri: df.uri, + name: df.name, + mimeType: df.mimeType, + size: df.size, + syncStatus: 'local', + createdAt: df.createdAt, + }); + } + } + + const result = Array.from(merged.values()); + console.log(`[useLocalFiles] registry=${registryEntries.length} device=${deviceFiles.length} merged=${result.length}`); + return result; + }, [deviceFiles, registryEntries]); + + return { + localFiles, + isLoading: deviceLoading, + hasPermission, + requestPermission, + rescan, + }; +} diff --git a/mobile/hooks/useUnifiedFiles.ts b/mobile/hooks/useUnifiedFiles.ts new file mode 100644 index 0000000..171dbcf --- /dev/null +++ b/mobile/hooks/useUnifiedFiles.ts @@ -0,0 +1,233 @@ +import { useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { downloadAsync, documentDirectory, makeDirectoryAsync, deleteAsync } from 'expo-file-system/legacy'; +import { useFiles } from './useFiles'; +import { useLocalFiles } from './useLocalFiles'; +import { localFileRegistry } from '../services/localFileRegistry'; +import { apiClient } from '../api/client'; +import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types'; + +export interface UnifiedFileItem { + id: string; + backendFileId?: string; + name: string; + mimeType: string; + size: number; + createdAt: string; + updatedAt?: string; + syncStatus: SyncStatus; + localUri?: string; + ocrText?: string; + tags: Tag[]; + isFolder: boolean; + parentFileId?: string; + url?: string; + thumbnailUrl?: string; + isDeviceFile?: boolean; +} + +const DOWNLOAD_DIR = `${documentDirectory}synced-files/`; + +function getCacheKey(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + return Math.abs(hash).toString(36); +} + +function getExtension(name: string): string { + const dot = name.lastIndexOf('.'); + return dot >= 0 ? name.slice(dot) : ''; +} + +export function useUnifiedFiles(page: number = 1, limit: number = 50) { + const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit); + const { localFiles, isLoading: localLoading, hasPermission, requestPermission } = useLocalFiles(); + + const unifiedFiles = useMemo(() => { + const backendFiles = backendData?.data ?? []; + const merged = new Map(); + + const registryAll = localFileRegistry.getAll(); + const backendIdToLocal = new Map(); + for (const entry of registryAll) { + if (entry.backendFileId) { + backendIdToLocal.set(entry.backendFileId, entry); + } + } + + for (const bf of backendFiles) { + const localEntry = backendIdToLocal.get(bf.id); + merged.set(bf.id, { + id: bf.id, + backendFileId: bf.id, + name: bf.name, + mimeType: bf.mimeType, + size: bf.size, + createdAt: bf.createdAt, + updatedAt: bf.updatedAt, + syncStatus: localEntry ? 'synced' : 'cloud', + localUri: localEntry?.localUri, + ocrText: bf.ocrText, + tags: bf.tags, + isFolder: bf.isFolder, + parentFileId: bf.parentFileId, + url: bf.url, + thumbnailUrl: bf.thumbnailUrl, + }); + } + + for (const lf of localFiles) { + if (lf.backendFileId && merged.has(lf.backendFileId)) continue; + if (merged.has(lf.id)) continue; + + merged.set(lf.id, { + id: lf.id, + backendFileId: lf.backendFileId, + name: lf.name, + mimeType: lf.mimeType, + size: lf.size, + createdAt: lf.createdAt, + syncStatus: 'local', + localUri: lf.localUri, + tags: lf.tags ?? [], + isFolder: false, + isDeviceFile: true, + }); + } + + return Array.from(merged.values()).sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; + }); + }, [backendData, localFiles]); + + return { + data: unifiedFiles, + isLoading: backendLoading || localLoading, + error: backendError, + hasPermission, + requestPermission, + }; +} + +export function useUnifiedFilesByParent(parentId: string) { + const { data: backendData, isLoading: backendLoading } = useQuery({ + queryKey: ['files', 'parent', parentId], + queryFn: () => + apiClient.get<{ data: FileItem[] }>(`/files/folders/${parentId}/files?thumbnail=thumbnail`), + enabled: !!parentId, + }); + + const { localFiles, isLoading: localLoading } = useLocalFiles(); + + const unifiedFiles = useMemo(() => { + const backendFiles = backendData?.data ?? []; + const merged = new Map(); + + const registryAll = localFileRegistry.getAll(); + const backendIdToLocal = new Map(); + for (const entry of registryAll) { + if (entry.backendFileId) backendIdToLocal.set(entry.backendFileId, entry); + } + + for (const bf of backendFiles) { + const localEntry = backendIdToLocal.get(bf.id); + merged.set(bf.id, { + id: bf.id, + backendFileId: bf.id, + name: bf.name, + mimeType: bf.mimeType, + size: bf.size, + createdAt: bf.createdAt, + updatedAt: bf.updatedAt, + syncStatus: localEntry ? 'synced' : 'cloud', + localUri: localEntry?.localUri, + ocrText: bf.ocrText, + tags: bf.tags, + isFolder: bf.isFolder, + parentFileId: bf.parentFileId, + url: bf.url, + thumbnailUrl: bf.thumbnailUrl, + }); + } + + return Array.from(merged.values()).sort((a, b) => { + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + }, [backendData, localFiles]); + + return { + data: unifiedFiles, + isLoading: backendLoading || localLoading, + }; +} + +export function useDownloadFile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (file: UnifiedFileItem): Promise => { + if (file.syncStatus !== 'cloud') { + return file.localUri ?? ''; + } + + const res = await apiClient.get<{ data: { url: string; name: string } }>( + `/files/${file.backendFileId}` + ); + const downloadUrl = res.data.url; + + await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true }); + const cacheKey = getCacheKey(file.name); + const ext = getExtension(file.name); + const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`; + + const result = await downloadAsync(downloadUrl, fileUri); + + const entry: LocalFileEntry = { + id: `local_${file.backendFileId}`, + backendFileId: file.backendFileId, + localUri: result.uri, + name: file.name, + mimeType: file.mimeType, + size: file.size, + syncStatus: 'synced', + createdAt: file.createdAt, + tags: file.tags, + }; + localFileRegistry.register(entry); + + queryClient.invalidateQueries({ queryKey: ['files'] }); + + return result.uri; + }, + }); +} + +export function useFreeLocalSpace() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (fileIds: string[]) => { + for (const fid of fileIds) { + const entry = localFileRegistry.get(fid); + if (!entry) continue; + + if (entry.localUri) { + try { + await deleteAsync(entry.localUri, { idempotent: true }); + } catch {} + } + + localFileRegistry.markAsCloudOnly(fid); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + }, + }); +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index a20aca9..bf386f1 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -16,6 +16,7 @@ "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", "expo-image-picker": "~57.0.2", + "expo-media-library": "~57.0.3", "expo-print": "~57.0.0", "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", @@ -3091,6 +3092,16 @@ "expo": "*" } }, + "node_modules/expo-media-library": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-57.0.3.tgz", + "integrity": "sha512-VI2NEDaMWIfz9Gxgibzu8BxxxVvgwHRAeZDjV9gci9YH+JkXn5vdvNAnRdmojaPxpp3tbWMfMDB1ympFcrak9A==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.5", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.5.tgz", diff --git a/mobile/package.json b/mobile/package.json index 40e847d..0c31310 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -11,6 +11,7 @@ "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", "expo-image-picker": "~57.0.2", + "expo-media-library": "~57.0.3", "expo-print": "~57.0.0", "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", diff --git a/mobile/services/localFileRegistry.ts b/mobile/services/localFileRegistry.ts new file mode 100644 index 0000000..8083fa6 --- /dev/null +++ b/mobile/services/localFileRegistry.ts @@ -0,0 +1,82 @@ +import { createMMKV } from 'react-native-mmkv'; +import { LocalFileEntry, SyncStatus } from '../types'; + +const storage = createMMKV({ id: 'vaultdrop-local-files' }); + +const INDEX_KEY = 'local_files_index'; + +function getAllIds(): string[] { + const raw = storage.getString(INDEX_KEY); + if (!raw) return []; + return JSON.parse(raw) as string[]; +} + +function setAllIds(ids: string[]) { + storage.set(INDEX_KEY, JSON.stringify(ids)); +} + +function entryKey(id: string): string { + return `local_file_${id}`; +} + +export const localFileRegistry = { + register(entry: LocalFileEntry) { + storage.set(entryKey(entry.id), JSON.stringify(entry)); + const ids = getAllIds(); + if (!ids.includes(entry.id)) { + setAllIds([entry.id, ...ids]); + } + }, + + get(id: string): LocalFileEntry | undefined { + const raw = storage.getString(entryKey(id)); + if (!raw) return undefined; + return JSON.parse(raw) as LocalFileEntry; + }, + + getByBackendId(backendId: string): LocalFileEntry | undefined { + const ids = getAllIds(); + for (const id of ids) { + const entry = this.get(id); + if (entry?.backendFileId === backendId) return entry; + } + return undefined; + }, + + getAll(): LocalFileEntry[] { + const ids = getAllIds(); + return ids + .map((id) => this.get(id)) + .filter((e): e is LocalFileEntry => e !== undefined); + }, + + update(id: string, updates: Partial) { + const existing = this.get(id); + if (!existing) return; + const updated = { ...existing, ...updates }; + storage.set(entryKey(id), JSON.stringify(updated)); + }, + + updateSyncStatus(id: string, syncStatus: SyncStatus) { + this.update(id, { syncStatus }); + }, + + markAsCloudOnly(id: string) { + this.update(id, { syncStatus: 'cloud', localUri: '' }); + }, + + remove(id: string) { + storage.remove(entryKey(id)); + const ids = getAllIds().filter((i) => i !== id); + setAllIds(ids); + }, + + removeByBackendId(backendId: string) { + const entry = this.getByBackendId(backendId); + if (entry) this.remove(entry.id); + }, + + count(): number { + return getAllIds().length; + }, +}; diff --git a/mobile/types/index.ts b/mobile/types/index.ts index e9c806c..70646b1 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -24,7 +24,7 @@ export interface FileItem { thumbnails?: Thumbnail[]; } -export function isFolder(file: FileItem): boolean { +export function isFolder(file: FileItem | { isFolder: boolean }): boolean { return file.isFolder; } @@ -116,3 +116,17 @@ export interface RefreshResponse { access_token: string; refresh_token: string; } + +export type SyncStatus = 'local' | 'synced' | 'cloud'; + +export interface LocalFileEntry { + id: string; + backendFileId?: string; + localUri: string; + name: string; + mimeType: string; + size: number; + syncStatus: SyncStatus; + createdAt: string; + tags?: Tag[]; +}