load local files

This commit is contained in:
m
2026-07-27 15:52:51 +02:00
parent 8efa1e18c2
commit 0cc261105a
13 changed files with 737 additions and 45 deletions
+11 -2
View File
@@ -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."
}
]
]
}
}
+89 -10
View File
@@ -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<RootStackParamList, 'FileDetail'>;
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 (
<ScrollView style={styles.page} contentContainerStyle={styles.pageContent}>
{hasPages ? (
@@ -75,13 +108,31 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
<Image source={{ uri }} style={styles.image} resizeMode="contain" />
</Pressable>
) : file ? (
<FileThumbnail
thumbnailUrl={file.data?.thumbnailUrl}
mimeType={file.mimeType ?? 'application/pdf'}
fileName={file.name ?? fileId}
size={SCREEN_WIDTH * 0.6}
isLoading={imageLoading}
/>
<View style={styles.cloudOnlyContainer}>
<FileThumbnail
thumbnailUrl={file.data?.thumbnailUrl}
mimeType={file.mimeType ?? 'application/pdf'}
fileName={file.name ?? fileId}
size={SCREEN_WIDTH * 0.6}
isLoading={imageLoading}
/>
{syncStatus === 'cloud' && (
<TouchableOpacity
style={styles.downloadBtn}
onPress={handleDownload}
disabled={downloading}
>
{downloading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<MaterialIcons name="cloud-download" size={20} color="#fff" />
)}
<Text style={styles.downloadBtnText}>
{downloading ? 'Téléchargement...' : 'Télécharger'}
</Text>
</TouchableOpacity>
)}
</View>
) : (
<ActivityIndicator size="large" color="#1976D2" />
)}
@@ -89,7 +140,10 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
)}
<View style={styles.details}>
<Text style={styles.fileName}>{imageData?.data?.name ?? file?.name ?? fileId}</Text>
<View style={styles.detailHeader}>
<Text style={styles.fileName}>{imageData?.data?.name ?? file?.name ?? fileId}</Text>
<SyncStatusBadge status={syncStatus} size={20} />
</View>
{imageData?.data?.size != null && (
<Text style={styles.meta}>
@@ -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,
+11 -16
View File
@@ -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<RootStackParamList>;
type FolderRouteProp = RouteProp<RootStackParamList, 'Folder'>;
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}
>
<FileThumbnail
uri={data?.data?.url}
uri={data?.data?.url ?? file.localUri}
thumbnailUrl={file.thumbnailUrl}
mimeType={file.mimeType}
fileName={file.name}
size={ITEM_SIZE}
isLoading={isLoading}
syncStatus={file.syncStatus}
/>
{selected && (
<View style={styles.selectedOverlay}>
@@ -62,7 +65,7 @@ export function FolderScreen() {
const route = useRoute<FolderRouteProp>();
const navigation = useNavigation<NavigationProp>();
const { folderId, folderName } = route.params;
const { data, isLoading, error } = useFilesByParent(folderId);
const { data, isLoading } = useUnifiedFilesByParent(folderId);
const deleteFile = useDeleteFile();
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
<View style={styles.center}>
<Text style={styles.infoText}>Erreur de chargement</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
+98 -14
View File
@@ -4,7 +4,9 @@ import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons';
import { useFiles, useFileImage, useDeleteFile, useAddTags, useMoveFiles, useFolders } from '../hooks/useFiles';
import { useDeleteFile, useAddTags, useMoveFiles, useFolders, useFileImage } from '../hooks/useFiles';
import { useUnifiedFiles, useFreeLocalSpace } from '../hooks/useUnifiedFiles';
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
import { FileItem, isFolder } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail';
@@ -24,7 +26,7 @@ type RootStackParamList = {
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type GroupedFiles = Record<string, FileItem[]>;
type GroupedFiles = Record<string, UnifiedFileItem[]>;
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}
>
<FileThumbnail
uri={data?.data?.url}
uri={data?.data?.url ?? file.localUri}
thumbnailUrl={file.thumbnailUrl}
mimeType={file.mimeType}
fileName={file.name}
size={ITEM_SIZE}
isLoading={isLoading}
syncStatus={file.syncStatus}
/>
{selected && (
<View style={styles.selectedOverlay}>
@@ -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<NavigationProp>();
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<SearchFilters>({ 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 (
<View style={styles.center}>
<Text style={styles.infoText}>Chargement...</Text>
{!hasPermission ? (
<View style={styles.permissionBanner}>
<MaterialIcons name="photo-library" size={28} color="#1976D2" />
<Text style={styles.permissionText}>
Autorisez l'accès à vos photos pour les afficher ici
</Text>
<TouchableOpacity style={styles.permissionBtn} onPress={requestPermission}>
<Text style={styles.permissionBtnText}>Autoriser</Text>
</TouchableOpacity>
</View>
) : (
<Text style={styles.infoText}>Chargement...</Text>
)}
</View>
);
}
@@ -281,6 +322,17 @@ export function HomeScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 96 : 0}
>
{!hasPermission && (
<View style={styles.permissionBanner}>
<MaterialIcons name="photo-library" size={28} color="#1976D2" />
<Text style={styles.permissionText}>
Autorisez l'accès à vos photos pour les afficher ici
</Text>
<TouchableOpacity style={styles.permissionBtn} onPress={requestPermission}>
<Text style={styles.permissionBtnText}>Autoriser</Text>
</TouchableOpacity>
</View>
)}
<FlatList
data={groupKeys}
keyExtractor={(item) => item}
@@ -386,6 +438,13 @@ export function HomeScreen() {
<MaterialIcons name="drive-file-move" size={18} color="#00897B" />
<Text style={[styles.chipText, { color: '#00897B' }]}>Déplacer</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.selectionChip, styles.freeSpaceChip]}
onPress={handleFreeSpace}
>
<MaterialIcons name="free-cancellation" size={18} color="#FF8F00" />
<Text style={[styles.chipText, { color: '#FF8F00' }]}>Libérer</Text>
</TouchableOpacity>
</ScrollView>
</View>
) : (
@@ -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',
+11 -2
View File
@@ -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<typeof MaterialIcons>['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 <Image source={{ uri: imageUri }} style={[styles.image, { width: size, height: size }]} />;
return (
<View style={{ width: size, height: size }}>
<Image source={{ uri: imageUri }} style={[styles.image, { width: size, height: size }]} />
{syncStatus && <SyncStatusBadge status={syncStatus} />}
</View>
);
}
return (
@@ -61,6 +69,7 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
{ext.length <= 4 && (
<Text style={[styles.ext, { color: info.color }]}>{ext}</Text>
)}
{syncStatus && <SyncStatusBadge status={syncStatus} />}
</View>
);
}
+38
View File
@@ -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<SyncStatus, { icon: string; color: string; bg: string }> = {
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 (
<View style={[styles.badge, { width: size, height: size, borderRadius: size / 2, backgroundColor: config.bg }]}>
<MaterialIcons name={config.icon as any} size={iconSize} color={config.color} />
</View>
);
}
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)',
},
});
+93
View File
@@ -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<DeviceFile[]>([]);
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,
};
}
+44
View File
@@ -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<string, LocalFileEntry>();
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,
};
}
+233
View File
@@ -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<string, UnifiedFileItem>();
const registryAll = localFileRegistry.getAll();
const backendIdToLocal = new Map<string, LocalFileEntry>();
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<string, UnifiedFileItem>();
const registryAll = localFileRegistry.getAll();
const backendIdToLocal = new Map<string, LocalFileEntry>();
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<string> => {
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'] });
},
});
}
+11
View File
@@ -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",
+1
View File
@@ -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",
+82
View File
@@ -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<LocalFileEntry>) {
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;
},
};
+15 -1
View File
@@ -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[];
}