from scratch

This commit is contained in:
m
2026-09-09 18:23:01 +02:00
parent c7467ea8f9
commit 2ba837bcb0
87 changed files with 721 additions and 15218 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+18 -138
View File
@@ -1,144 +1,24 @@
import React, { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { ActivityIndicator, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { DeviceProvider, useDevice } from './contexts/DeviceContext';
import { SseProvider } from './contexts/SseContext';
import { SseOcrListener } from './hooks/usePollOcr';
import { SseResourceListener } from './hooks/useSSEResource';
import { registerBackgroundUpload } from './services/backgroundUpload';
import { LoginScreen } from './app/login';
import { RegisterScreen } from './app/register';
import { HomeScreen } from './app/index';
import { ScanScreen } from './app/scan';
import { SearchScreen } from './app/search';
import { BatchReviewScreen } from './app/batch-review';
import { PendingReviewScreen } from './app/pending-review';
import { FileDetailScreen } from './app/file-detail';
import { FileEditScreen } from './app/file-edit';
import { FolderScreen } from './app/folder';
import { OnboardingScreen } from './app/onboarding';
import { SyncDetailScreen } from './app/sync-detail';
import { onboardingStorage } from './services/onboardingStorage';
import { initDB } from './services/fileStore';
import { migrateFromLegacy } from './services/fileStore/migrate';
import { createMMKVPersister } from './services/mmkvPersister';
import { resetSyncState } from './hooks/useSyncQueue';
initDB();
migrateFromLegacy();
resetSyncState();
const Stack = createNativeStackNavigator();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24h
},
},
});
const persister = createMMKVPersister();
function AppNavigator() {
const { user, isLoading: authLoading } = useAuth();
const { isLoading: deviceLoading } = useDevice();
useEffect(() => {
if (user) {
registerBackgroundUpload();
}
}, [user]);
if (authLoading || (user && deviceLoading)) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
const needsOnboarding = user && onboardingStorage.needsOnboarding();
return (
<NavigationContainer>
<Stack.Navigator
initialRouteName={
needsOnboarding ? 'Onboarding'
: user ? 'Home'
: 'Login'
}
>
{user ? (
<>
<Stack.Screen
name="Onboarding"
component={OnboardingScreen}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
title: 'Dot.',
headerTitleStyle: { fontSize: 18 },
}}
/>
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Scan' }} />
<Stack.Screen name="Search" component={SearchScreen} options={{ title: 'Recherche' }} />
<Stack.Screen name="BatchReview" component={BatchReviewScreen} options={{ title: 'Revue du lot' }} />
<Stack.Screen name="PendingReview" component={PendingReviewScreen} options={{ title: 'Réorganisation' }} />
<Stack.Screen
name="FileDetail"
component={FileDetailScreen}
options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' }, animation: 'fade' }}
/>
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
<Stack.Screen name="SyncDetail" component={SyncDetailScreen} options={{ title: 'Synchronisation' }} />
</>
) : (
<>
<Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} />
<Stack.Screen name="Register" component={RegisterScreen} options={{ headerShown: false }} />
</>
)}
</Stack.Navigator>
</NavigationContainer>
);
}
function AppContent() {
return (
<AuthProvider>
<SseProvider>
<SseOcrListener />
<SseResourceListener />
<DeviceProvider>
<AppNavigator />
<StatusBar style="auto" />
</DeviceProvider>
</SseProvider>
</AuthProvider>
);
}
export default function App() { export default function App() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <View style={styles.container}>
<PersistQueryClientProvider <Text style={styles.title}>Dot.</Text>
client={queryClient} <StatusBar style="auto" />
persistOptions={{ </View>
persister,
maxAge: 1000 * 60 * 60 * 24, // 24h
}}
>
<AppContent />
</PersistQueryClientProvider>
</GestureHandlerRootView>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 32,
fontWeight: '600',
},
});
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-176
View File
@@ -1,176 +0,0 @@
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, HttpError } from '../types';
import { tokenStorage } from './secureStorage';
class ApiClient {
private baseUrl: string;
private accessToken: string | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
setAccessToken(token: string | null) {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
private async request<T>(
endpoint: string,
options: RequestInit = {},
isRetry = false
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401 && !isRetry) {
const refreshed = await this.tryRefreshToken();
if (refreshed) {
return this.request<T>(endpoint, options, true);
}
throw new HttpError(401, 'Session expirée', 'UNAUTHORIZED');
}
if (!response.ok) {
let message = 'Request failed';
let code: string | undefined;
try {
const body: ApiError = await response.json();
message = body.error?.message || message;
code = body.error?.code;
} catch {}
throw new HttpError(response.status, message, code);
}
return response.json();
}
private async tryRefreshToken(): Promise<boolean> {
try {
const refreshToken = await tokenStorage.getRefreshToken();
if (!refreshToken) return false;
const url = `${this.baseUrl}${ENDPOINTS.AUTH_REFRESH}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
await this.clearAuth();
return false;
}
const data = await response.json();
this.accessToken = data.access_token;
await tokenStorage.setAccessToken(data.access_token);
await tokenStorage.setRefreshToken(data.refresh_token);
return true;
} catch {
await this.clearAuth();
return false;
}
}
private async clearAuth() {
this.accessToken = null;
await tokenStorage.deleteAccessToken();
await tokenStorage.deleteRefreshToken();
await tokenStorage.deleteUser();
}
async subscribeEvents(
onEvent: (event: string, data: string) => void,
onError: (err: Error) => void,
): Promise<() => void> {
const abortController = new AbortController();
const connect = async () => {
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
};
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
const response = await fetch(`${this.baseUrl}${ENDPOINTS.EVENTS}`, {
headers,
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`SSE connection failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('Streaming not supported');
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';
while (!abortController.signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7);
} else if (line.startsWith('data: ')) {
if (currentEvent) {
onEvent(currentEvent, line.slice(6));
currentEvent = '';
}
}
}
}
} catch (err) {
if (!abortController.signal.aborted) {
onError(err instanceof Error ? err : new Error(String(err)));
}
}
};
connect();
return () => abortController.abort();
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint);
}
async post<T>(endpoint: string, body?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}
export const apiClient = new ApiClient(API_BASE_URL);
-43
View File
@@ -1,43 +0,0 @@
import * as SecureStore from 'expo-secure-store';
const ACCESS_KEY = 'vaultdrop_access_token';
const REFRESH_KEY = 'vaultdrop_refresh_token';
const USER_KEY = 'vaultdrop_user';
export const tokenStorage = {
async getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(ACCESS_KEY);
},
async setAccessToken(token: string): Promise<void> {
await SecureStore.setItemAsync(ACCESS_KEY, token);
},
async deleteAccessToken(): Promise<void> {
await SecureStore.deleteItemAsync(ACCESS_KEY);
},
async getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync(REFRESH_KEY);
},
async setRefreshToken(token: string): Promise<void> {
await SecureStore.setItemAsync(REFRESH_KEY, token);
},
async deleteRefreshToken(): Promise<void> {
await SecureStore.deleteItemAsync(REFRESH_KEY);
},
async getUser(): Promise<string | null> {
return SecureStore.getItemAsync(USER_KEY);
},
async setUser(user: string): Promise<void> {
await SecureStore.setItemAsync(USER_KEY, user);
},
async deleteUser(): Promise<void> {
await SecureStore.deleteItemAsync(USER_KEY);
},
};
+3 -20
View File
@@ -2,42 +2,25 @@
"expo": { "expo": {
"name": "webui", "name": "webui",
"slug": "webui", "slug": "webui",
"version": "1.0.0", "version": "2.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "VaultDrop a besoin d'accéder à votre caméra pour scanner des documents."
},
"bundleIdentifier": "com.anonymous.webui" "bundleIdentifier": "com.anonymous.webui"
}, },
"android": { "android": {
"permissions": [
"CAMERA",
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE",
"RECEIVE_BOOT_COMPLETED"
],
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png", "backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png" "monochromeImage": "./assets/android-icon-monochrome.png"
}, },
"predictiveBackGestureEnabled": false,
"package": "com.anonymous.webui" "package": "com.anonymous.webui"
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, }
"plugins": [
"expo-secure-store",
"expo-image",
"expo-sqlite",
"expo-background-task",
"expo-status-bar"
]
} }
} }
-234
View File
@@ -1,234 +0,0 @@
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useBatchStore } from '../hooks/useBatchStore';
import { usePdfGeneration } from '../hooks/usePdfGeneration';
import { ConfirmModal } from '../components/ConfirmModal';
type RootStackParamList = {
Home: undefined;
BatchReview: { batchId: string };
PendingReview: { batchId: string; photoIds: string[] };
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type BatchReviewRouteParams = { BatchReview: { batchId: string } };
export function BatchReviewScreen() {
const route = useRoute<RouteProp<BatchReviewRouteParams, 'BatchReview'>>();
const navigation = useNavigation<NavigationProp>();
const { getBatch, removePhotoFromBatch } = useBatchStore();
const batch = getBatch(route.params.batchId);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
if (!batch) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Lot introuvable</Text>
</View>
);
}
const toggleSelection = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectedCount = selectedIds.size;
const handleDelete = () => {
if (selectedCount === 0) return;
setConfirmDeleteVisible(true);
};
const handleDeleteConfirm = () => {
selectedIds.forEach((id) => removePhotoFromBatch(batch.id, id));
setSelectedIds(new Set());
};
const handleGroup = () => {
if (selectedCount === 0) return;
const ids = Array.from(selectedIds);
navigation.navigate('PendingReview', { batchId: batch.id, photoIds: ids });
};
const formatDate = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{batch.name}</Text>
<Text style={styles.subtitle}>
{batch.photos.length} photo{batch.photos.length > 1 ? 's' : ''} {formatDate(batch.createdAt)}
</Text>
</View>
<FlatList
data={batch.photos}
numColumns={3}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.grid}
renderItem={({ item }) => {
const isSelected = selectedIds.has(item.id);
return (
<TouchableOpacity
style={[styles.gridItem, isSelected && styles.gridItemSelected]}
onPress={() => toggleSelection(item.id)}
>
<Image source={{ uri: item.uri }} style={styles.thumb} />
{isSelected && (
<View style={styles.selectedOverlay}>
<Text style={styles.selectedCheck}></Text>
</View>
)}
</TouchableOpacity>
);
}}
/>
<View style={styles.footer}>
<Text style={styles.selectionInfo}>
{selectedCount > 0 ? `${selectedCount} sélectionnée${selectedCount > 1 ? 's' : ''}` : 'Touchez une photo pour sélectionner'}
</Text>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.actionBtn, styles.deleteBtn, selectedCount === 0 && styles.actionBtnDisabled]}
onPress={handleDelete}
disabled={selectedCount === 0}
>
<Text style={styles.actionText}>🗑 Supprimer</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.groupBtn, selectedCount === 0 && styles.actionBtnDisabled]}
onPress={handleGroup}
disabled={selectedCount === 0}
>
<Text style={styles.actionText}>📦 Regrouper</Text>
</TouchableOpacity>
</View>
</View>
<ConfirmModal
visible={confirmDeleteVisible}
title="Supprimer"
message={`Retirer ${selectedCount === 1 ? 'cette photo' : `ces ${selectedCount} photos`} du lot ?`}
options={[
{ label: 'Annuler' },
{ label: 'Supprimer', destructive: true, onPress: handleDeleteConfirm },
]}
onClose={() => setConfirmDeleteVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: '#666',
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#666',
},
grid: {
padding: 4,
},
gridItem: {
flex: 1 / 3,
aspectRatio: 1,
margin: 4,
borderRadius: 6,
overflow: 'hidden',
backgroundColor: '#f0f0f0',
},
gridItemSelected: {
borderWidth: 3,
borderColor: '#1976D2',
},
thumb: {
width: '100%',
height: '100%',
},
selectedOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(25,118,210,0.3)',
justifyContent: 'center',
alignItems: 'center',
},
selectedCheck: {
color: '#fff',
fontSize: 32,
fontWeight: '700',
},
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 12,
},
selectionInfo: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
actions: {
flexDirection: 'row',
gap: 12,
},
actionBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
actionBtnDisabled: {
opacity: 0.4,
},
deleteBtn: {
backgroundColor: '#F44336',
},
groupBtn: {
backgroundColor: '#4CAF50',
},
actionText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
-129
View File
@@ -1,129 +0,0 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Platform } from 'react-native';
import { useNavigation, CommonActions } from '@react-navigation/native';
import { useDevice } from '../contexts/DeviceContext';
export function DeviceSetupScreen() {
const navigation = useNavigation();
const { register, deviceName } = useDevice();
const [name, setName] = useState(deviceName || `${Platform.OS.charAt(0).toUpperCase() + Platform.OS.slice(1)} de ${Platform.OS === 'ios' ? 'l\'utilisateur' : 'utilisateur'}`);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRegister = async () => {
if (!name.trim()) return;
setIsLoading(true);
setError(null);
try {
await register(name.trim());
navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'Home' }] }));
} catch (err: any) {
setError(err?.message || "Échec de l'enregistrement");
} finally {
setIsLoading(false);
}
};
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.icon}>📱</Text>
<Text style={styles.title}>Enregistrement du device</Text>
<Text style={styles.subtitle}>
Ce device doit être enregistré comme espace de stockage pour utiliser VaultDrop.
</Text>
<TextInput
style={styles.input}
placeholder="Nom du device"
placeholderTextColor="#999"
value={name}
onChangeText={setName}
autoFocus
returnKeyType="done"
onSubmitEditing={handleRegister}
/>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity
style={[styles.button, !name.trim() && styles.buttonDisabled]}
onPress={handleRegister}
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Enregistrer</Text>
)}
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
justifyContent: 'center',
padding: 24,
},
content: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 24,
alignItems: 'center',
gap: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
icon: {
fontSize: 48,
},
title: {
fontSize: 22,
fontWeight: '700',
color: '#333',
textAlign: 'center',
},
subtitle: {
fontSize: 15,
color: '#666',
textAlign: 'center',
lineHeight: 22,
},
input: {
width: '100%',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
color: '#333',
},
error: {
color: '#E53935',
fontSize: 14,
textAlign: 'center',
},
button: {
width: '100%',
backgroundColor: '#1976D2',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
},
buttonDisabled: {
backgroundColor: '#ccc',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
-697
View File
@@ -1,697 +0,0 @@
import React, { useRef, useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
Dimensions,
ActivityIndicator,
Modal,
Pressable,
TouchableOpacity,
Share,
} from 'react-native';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFile, useDownloadFile } from '../hooks/useFiles';
import { fileStore } from '../services/fileStore';
import { TagChip } from '../components/TagChip';
import { FileThumbnail } from '../components/FileThumbnail';
import { ConfirmModal } from '../components/ConfirmModal';
import { SyncStatusBadge } from '../components/SyncStatusBadge';
import { GestureHandlerRootView, Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS } from 'react-native-reanimated';
import { ZoomableImage } from '../components/ZoomableImage';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { downloadRegistry } from '../services/downloadRegistry';
import { deleteAsync } from 'expo-file-system/legacy';
import type { Variant, SyncStatus } from '../types';
const SCREEN_WIDTH = Dimensions.get('window').width;
type SelectedImage = { uri: string; width: number; height: number };
type ModalState = { images: SelectedImage[]; index: number } | null;
export type DeviceFileParam = {
localUri: string;
name: string;
mimeType: string;
createdAt: string;
};
type RootStackParamList = {
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, DeviceFileParam> };
};
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
const PANEL_HEIGHT = 410;
const PANEL_HEADER_VISIBLE = 100;
function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; deviceFile?: DeviceFileParam; onSelectImage?: (state: ModalState) => void }) {
const isDevice = !!deviceFile;
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const localEntry = fileStore.getById(fileId);
const apiId = isDevice ? '' : (localEntry?.backendId ?? fileId);
const { data: fileData } = useFile(apiId);
const downloadFile = useDownloadFile();
const [downloading, setDownloading] = useState(false);
const file = fileData as any;
const uri = isDevice ? deviceFile.localUri : (localEntry?.localUri ?? file?.url);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
const handleImageLoad = useCallback((event: { source: { width: number; height: number } }) => {
setImageSize({ width: event.source.width, height: event.source.height });
}, []);
const syncStatus: SyncStatus = isDevice
? 'local'
: localEntry
? (localEntry.syncStatus as SyncStatus)
: 'cloud';
const fullVariants: Variant[] = (file?.variants ?? [])
.filter((v: Variant) => v.variantType === 'thumbnail_full')
.sort((a: Variant, b: Variant) => a.pageNumber - b.pageNumber);
const fullThumbnails = fullVariants;
const hasPages = fullThumbnails.length > 0;
const fileName = deviceFile?.name ?? file?.name ?? fileId;
const createdAt = deviceFile?.createdAt ?? file?.createdAt;
const formattedDate = createdAt
? new Date(createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
const fileSize = file?.size ?? 0;
const formattedSize = fileSize > 0
? fileSize > 1024 * 1024
? `${(fileSize / (1024 * 1024)).toFixed(1)} Mo`
: `${(fileSize / 1024).toFixed(1)} Ko`
: '';
const [optionsVisible, setOptionsVisible] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
const panelOffset = useSharedValue(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
const panelStartY = useSharedValue(0);
const isPanelExpanded = useSharedValue(false);
const [panelExpanded, setPanelExpanded] = useState(false);
const handleDownload = useCallback(async () => {
if (!file) return;
const bid = localEntry?.backendId ?? fileId;
setDownloading(true);
try {
await downloadFile.mutateAsync({
id: bid,
backendResourceId: bid,
name: fileName,
mimeType: file?.mimeType ?? localEntry?.mimeType ?? 'application/octet-stream',
size: file?.size ?? localEntry?.size ?? 0,
createdAt: file?.createdAt ?? localEntry?.createdAt ?? new Date().toISOString(),
source: 'cloud',
syncStatus: 'cloud',
tags: file?.tags ?? [],
isFolder: false,
});
} catch {} finally {
setDownloading(false);
}
}, [file, fileName, fileId, localEntry, downloadFile]);
const handleShare = useCallback(async () => {
setOptionsVisible(false);
if (uri) {
await Share.share({ url: uri, title: fileName });
} else if (file?.url) {
await Share.share({ url: file.url, title: fileName });
}
}, [uri, fileName, file?.url]);
const handleDeleteConfirm = useCallback(async () => {
setDeleting(true);
try {
const bid = localEntry?.backendId ?? (file as any)?.backendResourceId;
if (bid) {
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${bid}`);
fileStore.deleteByBackendId(bid);
} else {
fileStore.deleteById(fileId);
}
if (localEntry?.localUri) {
await deleteAsync(localEntry.localUri, { idempotent: true });
}
downloadRegistry.remove(fileId);
navigation.goBack();
} catch {} finally {
setDeleting(false);
}
}, [fileName, fileId, localEntry, file, navigation]);
const handleDelete = useCallback(() => {
setOptionsVisible(false);
setConfirmDeleteVisible(true);
}, []);
const togglePanelJS = useCallback(() => {
if (isPanelExpanded.value) {
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
isPanelExpanded.value = false;
setPanelExpanded(false);
} else {
panelOffset.value = withSpring(0);
isPanelExpanded.value = true;
setPanelExpanded(true);
}
}, []);
const panGesture = Gesture.Pan()
.onStart(() => {
panelStartY.value = panelOffset.value;
})
.onUpdate((event) => {
const offset = Math.max(0, Math.min(PANEL_HEIGHT - PANEL_HEADER_VISIBLE, panelStartY.value + event.translationY));
panelOffset.value = offset;
})
.onEnd(() => {
if (panelOffset.value > (PANEL_HEIGHT - PANEL_HEADER_VISIBLE) / 2) {
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
isPanelExpanded.value = false;
runOnJS(setPanelExpanded)(false);
} else {
panelOffset.value = withSpring(0);
isPanelExpanded.value = true;
runOnJS(setPanelExpanded)(true);
}
});
const panelAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: panelOffset.value }],
}));
const renderImageContent = () => {
if (hasPages) {
return fullThumbnails.map((thumb, thumbIndex) => {
const allImages: SelectedImage[] = fullThumbnails.map((t) => ({
uri: t.url,
width: SCREEN_WIDTH,
height: t.height * (SCREEN_WIDTH / t.width),
}));
return (
<Pressable
key={thumb.id}
onPress={() => onSelectImage?.({ images: allImages, index: thumbIndex })}
>
<Image
source={{ uri: thumb.url }}
style={[styles.image, { height: thumb.height * (SCREEN_WIDTH / thumb.width) }]}
contentFit="contain"
cachePolicy="memory-disk"
transition={200}
/>
</Pressable>
);
});
}
if (uri) {
return (
<Image
source={{ uri }}
style={[
styles.image,
imageSize
? { height: imageSize.height * SCREEN_WIDTH / imageSize.width }
: { aspectRatio: 1 },
]}
contentFit="contain"
cachePolicy="memory-disk"
transition={200}
onLoad={handleImageLoad}
/>
);
}
if (file) {
return (
<View style={styles.cloudOnlyContainer}>
<FileThumbnail
thumbnailUrl={file?.thumbnailUrl}
mimeType={file?.mimeType ?? 'application/pdf'}
fileName={file?.name ?? fileId}
size={SCREEN_WIDTH * 0.5}
/>
{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>
);
}
return <ActivityIndicator size="large" color="#1976D2" />;
};
if (deleting) {
return (
<View style={[styles.detailContainer, styles.center]}>
<ActivityIndicator size="large" color="#fff" />
</View>
);
}
return (
<View style={styles.detailContainer}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => {
if (!hasPages && uri) {
const height = imageSize
? imageSize.height * SCREEN_WIDTH / imageSize.width
: SCREEN_WIDTH;
onSelectImage?.({
images: [{ uri, width: SCREEN_WIDTH, height }],
index: 0,
});
}
}}
>
<View style={styles.imageCentered}>
{renderImageContent()}
</View>
</Pressable>
<View style={[styles.headerOverlay, { paddingTop: insets.top + 8 }]}>
<Text style={styles.headerDate} numberOfLines={1}>{formattedDate}</Text>
<TouchableOpacity onPress={() => setOptionsVisible(true)} style={styles.headerBtn}>
<MaterialIcons name="more-vert" size={24} color="#fff" />
</TouchableOpacity>
</View>
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.panel, { paddingBottom: insets.bottom + 12 }, panelAnimatedStyle]}>
<TouchableOpacity onPress={togglePanelJS} activeOpacity={0.7}>
<View style={styles.panelHandle} />
<View style={styles.panelHeader}>
<Text style={styles.panelFileName} numberOfLines={1}>{fileName}</Text>
<SyncStatusBadge status={syncStatus} size={20} />
</View>
</TouchableOpacity>
<View style={styles.panelBody}>
{formattedDate ? (
<View style={styles.metaRow}>
<MaterialIcons name="calendar-today" size={16} color="#888" />
<Text style={styles.metaText}>{formattedDate}</Text>
</View>
) : null}
{formattedSize ? (
<View style={styles.metaRow}>
<MaterialIcons name="storage" size={16} color="#888" />
<Text style={styles.metaText}>{formattedSize}</Text>
</View>
) : null}
{file?.tags && file.tags.length > 0 && (
<View style={styles.tagsSection}>
<Text style={styles.sectionLabel}>Tags</Text>
<View style={styles.tagsRow}>
{file.tags.map((tag: any) => (
<TagChip key={tag.id} name={tag.name} />
))}
</View>
</View>
)}
{file?.ocrText && (
<View style={styles.ocrSection}>
<Text style={styles.sectionLabel}>Texte OCR</Text>
<Text style={styles.ocrText} numberOfLines={4}>{file.ocrText}</Text>
</View>
)}
<View style={styles.actionsRow}>
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
<MaterialIcons name="share" size={20} color="#1976D2" />
<Text style={styles.actionBtnText}>Partager</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete}>
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
<Text style={[styles.actionBtnText, { color: '#E53935' }]}>Supprimer</Text>
</TouchableOpacity>
</View>
</View>
</Animated.View>
</GestureDetector>
<Modal visible={optionsVisible} transparent animationType="fade" onRequestClose={() => setOptionsVisible(false)}>
<TouchableOpacity style={styles.optionsOverlay} activeOpacity={1} onPress={() => setOptionsVisible(false)}>
<View style={styles.optionsMenu}>
<TouchableOpacity style={styles.optionItem} onPress={handleShare}>
<MaterialIcons name="share" size={22} color="#333" />
<Text style={styles.optionText}>Partager</Text>
</TouchableOpacity>
<View style={styles.optionDivider} />
<TouchableOpacity style={styles.optionItem} onPress={handleDelete}>
<MaterialIcons name="delete-outline" size={22} color="#E53935" />
<Text style={[styles.optionText, { color: '#E53935' }]}>Supprimer</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
<ConfirmModal
visible={confirmDeleteVisible}
title="Supprimer"
message={`Supprimer "${fileName}" définitivement ?`}
options={[
{ label: 'Annuler' },
{ label: 'Supprimer', destructive: true, onPress: handleDeleteConfirm },
]}
onClose={() => setConfirmDeleteVisible(false)}
/>
</View>
);
}
export function FileDetailScreen() {
const route = useRoute<FileDetailRouteProp>();
const { fileIds, initialIndex, deviceFiles } = route.params;
const flatListRef = useRef<FlatList>(null);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [modalState, setModalState] = useState<ModalState>(null);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
setModalState((prev) => {
if (!prev) return prev;
const next = direction === 'up'
? Math.min(prev.index + 1, prev.images.length - 1)
: Math.max(prev.index - 1, 0);
if (next === prev.index) return prev;
return { ...prev, index: next };
});
}, []);
return (
<View style={styles.container}>
<FlatList
ref={flatListRef}
data={fileIds}
keyExtractor={(item) => item}
horizontal
snapToInterval={SCREEN_WIDTH}
decelerationRate="fast"
disableIntervalMomentum
showsHorizontalScrollIndicator={false}
initialScrollIndex={initialIndex}
windowSize={5}
initialNumToRender={3}
maxToRenderPerBatch={3}
getItemLayout={(_, index) => ({
length: SCREEN_WIDTH,
offset: SCREEN_WIDTH * index,
index,
})}
onMomentumScrollEnd={(e) => {
const index = Math.round(e.nativeEvent.contentOffset.x / SCREEN_WIDTH);
setCurrentIndex(index);
}}
renderItem={({ item }) => (
<View style={styles.pageWrapper}>
<DetailItem fileId={item} deviceFile={deviceFiles?.[item]} onSelectImage={setModalState} />
</View>
)}
/>
<View style={styles.pagination}>
<Text style={styles.paginationText}>
{currentIndex + 1} / {fileIds.length}
</Text>
</View>
<Modal
visible={modalState !== null}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setModalState(null)}
>
{modalState && (
<GestureHandlerRootView style={{ flex: 1 }}>
<ZoomableImage
key={modalState.images[modalState.index].uri}
uri={modalState.images[modalState.index].uri}
width={modalState.images[modalState.index].width}
height={modalState.images[modalState.index].height}
onClose={() => setModalState(null)}
onSwipeVertical={handleSwipeVertical}
/>
{modalState.images.length > 1 && (
<View style={styles.modalPagination}>
<Text style={styles.modalPaginationText}>
{modalState.index + 1} / {modalState.images.length}
</Text>
</View>
)}
</GestureHandlerRootView>
)}
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
pageWrapper: {
width: SCREEN_WIDTH,
},
detailContainer: {
flex: 1,
backgroundColor: '#000',
},
imageCentered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: SCREEN_WIDTH,
},
headerOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
paddingBottom: 8,
backgroundColor: 'rgba(0,0,0,0.3)',
},
headerBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
},
headerDate: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
flex: 1,
textAlign: 'center',
marginHorizontal: 8,
},
panel: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 20,
paddingTop: 12,
height: PANEL_HEIGHT,
},
panelHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#ddd',
alignSelf: 'center',
marginBottom: 12,
},
panelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
panelFileName: {
fontSize: 17,
fontWeight: '700',
color: '#333',
flex: 1,
marginRight: 8,
},
panelBody: {
flex: 1,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
},
metaText: {
fontSize: 14,
color: '#666',
},
tagsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginTop: 8,
},
tagsSection: {
marginTop: 8,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
color: '#333',
},
ocrSection: {
marginTop: 12,
},
ocrText: {
fontSize: 13,
color: '#555',
lineHeight: 18,
marginTop: 4,
},
actionsRow: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: '#f5f5f5',
},
actionBtnText: {
fontSize: 15,
fontWeight: '600',
color: '#1976D2',
},
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',
},
optionsOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
paddingBottom: 40,
},
optionsMenu: {
backgroundColor: '#fff',
borderRadius: 14,
marginHorizontal: 20,
overflow: 'hidden',
},
optionItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
paddingVertical: 16,
paddingHorizontal: 20,
},
optionText: {
fontSize: 16,
color: '#333',
},
optionDivider: {
height: 1,
backgroundColor: '#eee',
marginHorizontal: 20,
},
pagination: {
position: 'absolute',
bottom: 16,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 4,
},
paginationText: {
color: '#fff',
fontSize: 13,
},
modalPagination: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 4,
},
modalPaginationText: {
color: '#fff',
fontSize: 13,
},
});
-568
View File
@@ -1,568 +0,0 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
Dimensions,
Modal,
} from 'react-native';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useAddTags } from '../hooks/useFiles';
import { usePdfGeneration } from '../hooks/usePdfGeneration';
import { useUpload } from '../hooks/useUpload';
import { TagChip } from '../components/TagChip';
import { FileThumbnail } from '../components/FileThumbnail';
import { ZoomableImage } from '../components/ZoomableImage';
import { fileStore } from '../services/fileStore';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import type { SyncStatus, Variant } from '../types';
const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
const PADDING = 16;
const ITEM_GAP = 6;
const ITEM_SIZE = (SCREEN_WIDTH - PADDING * 2 - (NUM_COLUMNS - 1) * ITEM_GAP) / NUM_COLUMNS;
type FileEditRouteParams = {
FileEdit: { fileIds: string[] };
};
type PreviewFile = { uri: string; width: number; height: number };
interface FileEditItemProps {
fileId: string;
selected: boolean;
onSelect: (id: string) => void;
onPreview: (id: string) => void;
size: number;
}
const FileEditItem = React.memo(function FileEditItem({ fileId, selected, onSelect, onPreview, size }: FileEditItemProps) {
const record = fileStore.getByBackendId(fileId) ?? fileStore.getById(fileId);
const uri = record?.localUri ?? undefined;
const thumbnailUrl = record?.thumbnailUrl ?? undefined;
const mimeType = record?.mimeType ?? 'application/octet-stream';
const fileName = record?.name ?? fileId;
const syncStatus = (record?.syncStatus ?? 'cloud') as SyncStatus;
const isViewable = mimeType.startsWith('image/') || mimeType === 'application/pdf';
return (
<View style={{ width: size, height: size, marginBottom: ITEM_GAP, borderRadius: 6, overflow: 'hidden' }}>
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={0.7}
onPress={() => { if (isViewable) onPreview(fileId); else onSelect(fileId); }}
>
<FileThumbnail
uri={uri}
thumbnailUrl={thumbnailUrl}
mimeType={mimeType}
fileName={fileName}
size={size}
syncStatus={syncStatus}
/>
</TouchableOpacity>
{isViewable && (
<TouchableOpacity
style={styles.previewBtn}
onPress={() => onPreview(fileId)}
hitSlop={6}
>
<MaterialIcons name="visibility" size={16} color="#fff" />
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.selectBtn}
onPress={() => onSelect(fileId)}
hitSlop={8}
>
<View style={[styles.checkCircle, selected && styles.checkCircleSelected]}>
{selected && <MaterialIcons name="check" size={14} color="#fff" />}
</View>
</TouchableOpacity>
</View>
);
});
export function FileEditScreen() {
const route = useRoute<RouteProp<FileEditRouteParams, 'FileEdit'>>();
const navigation = useNavigation();
const { fileIds } = route.params;
const addTags = useAddTags();
const { generatePdf, generating, progress } = usePdfGeneration();
const upload = useUpload();
const [tagInput, setTagInput] = useState('');
const [pendingTags, setPendingTags] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [previewFiles, setPreviewFiles] = useState<PreviewFile[] | null>(null);
const [previewIndex, setPreviewIndex] = useState(0);
const [previewLoading, setPreviewLoading] = useState(false);
const hasSelection = selectedIds.size > 0;
const targetIds = hasSelection ? Array.from(selectedIds) : fileIds;
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) => {
if (prev.size === fileIds.length) return new Set();
return new Set(fileIds);
});
}, [fileIds]);
const openPreview = useCallback((files: PreviewFile[], index: number) => {
setPreviewFiles(files);
setPreviewIndex(index);
}, []);
const handlePreview = useCallback(async (fileId: string) => {
const record = fileStore.getByBackendId(fileId) ?? fileStore.getById(fileId);
if (record?.localUri) {
openPreview([{ uri: record.localUri, width: SCREEN_WIDTH, height: SCREEN_WIDTH }], 0);
return;
}
setPreviewLoading(true);
try {
const data = await apiClient.get<any>(`${ENDPOINTS.RESOURCES}/${fileId}`);
const url = data?.url;
if (url) {
const fullVariants: Variant[] = (data?.variants ?? [])
.filter((v: Variant) => v.variantType === 'thumbnail_full')
.sort((a: Variant, b: Variant) => a.pageNumber - b.pageNumber);
if (fullVariants.length > 0) {
const pages: PreviewFile[] = fullVariants.map((v) => ({
uri: v.url,
width: v.width,
height: v.height,
}));
openPreview(pages, 0);
} else {
openPreview([{ uri: url, width: SCREEN_WIDTH, height: SCREEN_WIDTH }], 0);
}
} else {
Alert.alert('Erreur', 'Impossible de charger l\'aperçu');
}
} catch {
Alert.alert('Erreur', 'Impossible de charger l\'aperçu');
} finally {
setPreviewLoading(false);
}
}, [openPreview]);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
setPreviewIndex((prev) => {
if (!previewFiles || previewFiles.length <= 1) return prev;
const next = direction === 'up'
? Math.min(prev + 1, previewFiles.length - 1)
: Math.max(prev - 1, 0);
return next;
});
}, [previewFiles]);
const handleAddTag = () => {
const tag = tagInput.trim().toLowerCase();
if (!tag || pendingTags.includes(tag)) return;
setPendingTags((prev) => [...prev, tag]);
setTagInput('');
};
const handleRemoveTag = (tag: string) => {
setPendingTags((prev) => prev.filter((t) => t !== tag));
};
const handleApplyTags = useCallback(async () => {
if (pendingTags.length === 0) return;
for (const fileId of targetIds) {
await addTags.mutateAsync({ fileId, tags: pendingTags });
}
Alert.alert('Succès', `${pendingTags.length} tag${pendingTags.length > 1 ? 's' : ''} ajouté${pendingTags.length > 1 ? 's' : ''}`);
setPendingTags([]);
}, [pendingTags, targetIds, addTags]);
const handleGeneratePdf = useCallback(async () => {
if (targetIds.length === 0) return;
setUploading(true);
try {
const results = await Promise.all(
targetIds.map(async (fileId) => {
const record = fileStore.getByBackendId(fileId) ?? fileStore.getById(fileId);
if (record?.localUri) return { uri: record.localUri };
const data = await apiClient.get<{ url: string }>(`${ENDPOINTS.RESOURCES}/${fileId}`);
return { uri: data?.url || '' };
})
);
const imageUris = results.filter((r) => r.uri);
if (imageUris.length === 0) {
Alert.alert('Erreur', 'Aucune image trouvée pour la génération du PDF');
setUploading(false);
return;
}
const pdfUri = await generatePdf(imageUris);
if (!pdfUri) {
Alert.alert('Erreur', 'Échec de la génération du PDF');
setUploading(false);
return;
}
Alert.alert(
'PDF généré',
'Voulez-vous uploader le fichier ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Uploader',
onPress: async () => {
try {
const pdfName = `document_${Date.now()}.pdf`;
const pdfUriClean = pdfUri.startsWith('file://') ? pdfUri : 'file://' + pdfUri;
await upload.mutateAsync([
{ uri: pdfUriClean, type: 'application/pdf', name: pdfName },
]);
Alert.alert('Succès', 'PDF uploadé avec succès', [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
} catch (e: any) {
const msg = e?.message || e?.toString() || 'Erreur inconnue';
Alert.alert('Erreur', `Échec de l'upload du PDF: ${msg}`);
}
},
},
]
);
} finally {
setUploading(false);
}
}, [targetIds, generatePdf, upload, navigation]);
const isLoading = generating || uploading || previewLoading;
const renderItem = useCallback(({ item }: { item: string }) => (
<FileEditItem
fileId={item}
selected={hasSelection ? selectedIds.has(item) : true}
onSelect={toggleSelection}
onPreview={handlePreview}
size={ITEM_SIZE}
/>
), [hasSelection, selectedIds, toggleSelection, handlePreview]);
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.headerRow}>
<View>
<Text style={styles.title}>Édition</Text>
<Text style={styles.subtitle}>
{hasSelection
? `${selectedIds.size} sélectionné${selectedIds.size > 1 ? 's' : ''} / ${fileIds.length}`
: `${fileIds.length} fichier${fileIds.length > 1 ? 's' : ''}`}
</Text>
</View>
<TouchableOpacity style={styles.selectAllBtn} onPress={toggleSelectAll}>
<Text style={styles.selectAllText}>
{hasSelection && selectedIds.size === fileIds.length ? 'Tout' : 'Tout'}
</Text>
</TouchableOpacity>
</View>
</View>
<FlatList
data={fileIds}
numColumns={NUM_COLUMNS}
keyExtractor={(item) => item}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
renderItem={renderItem}
/>
<View style={styles.tagSection}>
<Text style={styles.sectionTitle}>Tags</Text>
<View style={styles.tagRow}>
{pendingTags.map((tag) => (
<TouchableOpacity key={tag} style={styles.tagChip} onPress={() => handleRemoveTag(tag)}>
<TagChip name={tag} onRemove={() => handleRemoveTag(tag)} />
</TouchableOpacity>
))}
</View>
<View style={styles.tagInputRow}>
<TextInput
style={styles.tagInput}
placeholder="Ajouter un tag..."
value={tagInput}
onChangeText={setTagInput}
onSubmitEditing={handleAddTag}
returnKeyType="done"
/>
<TouchableOpacity style={styles.tagAddBtn} onPress={handleAddTag}>
<MaterialIcons name="add" size={22} color="#fff" />
</TouchableOpacity>
</View>
{pendingTags.length > 0 && (
<TouchableOpacity style={styles.applyTagsBtn} onPress={handleApplyTags} disabled={addTags.isPending}>
{addTags.isPending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.applyTagsText}>Appliquer les tags</Text>
)}
</TouchableOpacity>
)}
</View>
<View style={styles.footer}>
{(generating || uploading) && (
<View style={styles.progressRow}>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.progressText}>
{generating ? `Génération du PDF... ${progress}%` : 'Upload en cours...'}
</Text>
</View>
)}
{previewLoading && (
<View style={styles.progressRow}>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.progressText}>Chargement de l'aperçu...</Text>
</View>
)}
<TouchableOpacity
style={[styles.pdfBtn, isLoading && styles.pdfBtnDisabled]}
onPress={handleGeneratePdf}
disabled={isLoading}
>
{generating ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<MaterialIcons name="picture-as-pdf" size={20} color="#fff" />
)}
<Text style={styles.pdfBtnText}>Créer un PDF</Text>
</TouchableOpacity>
</View>
<Modal
visible={previewFiles !== null}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setPreviewFiles(null)}
>
{previewFiles && (
<GestureHandlerRootView style={{ flex: 1 }}>
<ZoomableImage
key={previewFiles[previewIndex].uri}
uri={previewFiles[previewIndex].uri}
width={previewFiles[previewIndex].width}
height={previewFiles[previewIndex].height}
onClose={() => setPreviewFiles(null)}
onSwipeVertical={handleSwipeVertical}
/>
{previewFiles.length > 1 && (
<View style={styles.modalPagination}>
<Text style={styles.modalPaginationText}>
{previewIndex + 1} / {previewFiles.length}
</Text>
</View>
)}
</GestureHandlerRootView>
)}
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
header: {
padding: PADDING,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#666',
},
selectAllBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#E3F2FD',
},
selectAllText: {
fontSize: 13,
fontWeight: '600',
color: '#1976D2',
},
grid: {
padding: PADDING,
},
gridRow: {
gap: ITEM_GAP,
},
tagSection: {
padding: PADDING,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginBottom: 8,
},
tagChip: {
marginRight: 2,
},
tagInputRow: {
flexDirection: 'row',
gap: 8,
},
tagInput: {
flex: 1,
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
padding: 10,
fontSize: 14,
},
tagAddBtn: {
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
},
applyTagsBtn: {
marginTop: 10,
backgroundColor: '#4CAF50',
paddingVertical: 10,
borderRadius: 8,
alignItems: 'center',
},
applyTagsText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
footer: {
padding: PADDING,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 8,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
progressText: {
fontSize: 14,
color: '#666',
},
pdfBtn: {
flexDirection: 'row',
backgroundColor: '#1976D2',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
pdfBtnDisabled: {
opacity: 0.6,
},
pdfBtnText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
previewBtn: {
position: 'absolute',
top: 4,
right: 28,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
selectBtn: {
position: 'absolute',
bottom: 4,
right: 4,
padding: 4,
},
checkCircle: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: '#fff',
backgroundColor: 'rgba(0,0,0,0.3)',
justifyContent: 'center',
alignItems: 'center',
},
checkCircleSelected: {
backgroundColor: '#1976D2',
borderColor: '#1976D2',
},
modalPagination: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 4,
},
modalPaginationText: {
color: '#fff',
fontSize: 13,
},
});
-476
View File
@@ -1,476 +0,0 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import { View, FlatList, StyleSheet, TouchableOpacity, Text, Modal, TextInput } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
useDeleteFile, useFiles, useFreeLocalSpace,
useAddTags, useMoveResources, useFolders, useCreateFolder,
} from '../hooks/useFiles';
import { SelectionPanel } from '../components/SelectionPanel';
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
import { UnifiedFileItem } from '../types';
import { isFolder } from '../types';
import { FileCard } from '../components/FileCard';
import { fileStore } from '../services/fileStore';
import { downloadRegistry } from '../services/downloadRegistry';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { useQueryClient } from '@tanstack/react-query';
import { deleteAsync } from 'expo-file-system/legacy';
const PAGE_SIZE = 100;
type RootStackParamList = {
Folder: { folderId: string; folderName: string };
FileDetail: { fileIds: string[]; initialIndex: number };
FileEdit: { fileIds: string[] };
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type FolderRouteProp = RouteProp<RootStackParamList, 'Folder'>;
export function FolderScreen() {
const route = useRoute<FolderRouteProp>();
const navigation = useNavigation<NavigationProp>();
const { folderId, folderName } = route.params;
const [page, setPage] = useState(1);
const { data, isLoading, isFetching } = useFiles(folderId, page, PAGE_SIZE);
const deleteFile = useDeleteFile();
const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const addTags = useAddTags();
const moveFiles = useMoveResources();
const createFolder = useCreateFolder();
const { data: foldersData } = useFolders();
const insets = useSafeAreaInsets();
const loadMore = useCallback(() => {
if (isFetching) return;
const total = data?.meta?.total ?? 0;
const loaded = data?.data?.length ?? 0;
if (loaded < total) {
setPage((p) => p + 1);
}
}, [isFetching, data?.meta?.total, data?.data?.length]);
const hasMore = (data?.data?.length ?? 0) > 0 && (data?.data?.length ?? 0) < (data?.meta?.total ?? 0);
const [tagModalVisible, setTagModalVisible] = useState(false);
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
const [tagInput, setTagInput] = useState('');
const [moveModalVisible, setMoveModalVisible] = useState(false);
const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null);
const selectionMode = selectedIds.size > 0;
useEffect(() => {
navigation.setOptions({ title: folderName });
}, [navigation, folderName]);
const files = useMemo(() => {
return data?.data ?? [];
}, [data]);
const fileIdToIndex = useMemo(() => {
const map = new Map<string, number>();
files.forEach((f, i) => map.set(f.id, i));
return map;
}, [files]);
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
}, []);
const handleDelete = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
const hasSynced = ids.some((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`;
const options: ConfirmOption[] = [];
if (hasSynced) {
options.push({
label: 'Du device uniquement',
onPress: async () => {
const syncedIds = ids.filter((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
if (syncedIds.length > 0) {
await freeLocalSpace.mutateAsync(syncedIds);
}
setSelectedIds(new Set());
},
});
}
options.push({
label: 'Du device + serveur',
destructive: true,
onPress: async () => {
for (const id of ids) {
const f = files.find((fi) => fi.id === id);
if (f?.backendResourceId) {
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${f.backendResourceId}`);
fileStore.deleteByBackendId(f.backendResourceId);
} else {
fileStore.deleteById(id);
}
if (f?.localUri) {
try { await deleteAsync(f.localUri, { idempotent: true }); } catch {}
}
downloadRegistry.remove(id);
}
await queryClient.invalidateQueries({ queryKey: ['resources'] });
setSelectedIds(new Set());
},
});
options.push({ label: 'Annuler' });
setConfirmDeleteState({ message: `Supprimer ${label} ?`, options });
}, [selectedIds, files, deleteFile, freeLocalSpace, queryClient]);
const handleEdit = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
navigation.navigate('FileEdit', { fileIds: ids });
setSelectedIds(new Set());
}, [selectedIds, navigation]);
const openTagModal = useCallback((mode: 'tag' | 'folder') => {
setTagModalMode(mode);
setTagInput('');
setTagModalVisible(true);
}, []);
const handleAddTag = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
for (const id of ids) {
await addTags.mutateAsync({ fileId: id, tags: [name] });
}
setTagModalVisible(false);
setSelectedIds(new Set());
}, [tagInput, selectedIds, addTags]);
const handleCreateFolderFromModal = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
try {
const newFolder = await createFolder.mutateAsync({ name, parentResourceId: folderId });
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: newFolder.id });
setTagModalVisible(false);
setSelectedIds(new Set());
navigation.navigate('Folder', { folderId: newFolder.id, folderName: newFolder.name });
} catch {}
}, [tagInput, selectedIds, createFolder, moveFiles, navigation, folderId]);
const handleMove = useCallback(async (folderId: string | null) => {
const ids = Array.from(selectedIds);
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: folderId });
setMoveModalVisible(false);
setSelectedIds(new Set());
}, [selectedIds, moveFiles]);
const handleItemPress = useCallback((file: UnifiedFileItem) => {
if (selectionMode) {
toggleSelection(file.id);
} else if (isFolder(file)) {
navigation.push('Folder', { folderId: file.id, folderName: file.name });
} else {
navigation.navigate('FileDetail', {
fileIds: files.map((f) => f.id),
initialIndex: fileIdToIndex.get(file.id) ?? 0,
});
}
}, [selectionMode, toggleSelection, navigation, files, fileIdToIndex]);
const handleItemLongPress = useCallback((file: UnifiedFileItem) => {
if (!selectionMode) toggleSelection(file.id);
}, [selectionMode, toggleSelection]);
if (isLoading) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Chargement...</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={files}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={
<View style={styles.empty}>
<MaterialIcons name="folder-open" size={48} color="#ccc" />
<Text style={styles.emptyText}>Dossier vide</Text>
</View>
}
ListFooterComponent={
isFetching ? (
<View style={styles.footer}>
<Text style={styles.footerText}>Chargement...</Text>
</View>
) : hasMore ? (
<TouchableOpacity style={styles.footer} onPress={loadMore}>
<Text style={styles.footerLink}>Charger plus</Text>
</TouchableOpacity>
) : null
}
renderItem={({ item: file }) => (
<FileCard
file={file}
selected={selectedIds.has(file.id)}
onPress={handleItemPress}
onLongPress={handleItemLongPress}
/>
)}
/>
{selectionMode && (
<SelectionPanel
selectedCount={selectedIds.size}
onClose={clearSelection}
onDelete={handleDelete}
onEdit={handleEdit}
onTags={() => openTagModal('tag')}
onFolder={() => openTagModal('folder')}
onMove={() => setMoveModalVisible(true)}
insetsBottom={insets.bottom}
/>
)}
<Modal visible={tagModalVisible} transparent animationType="fade" onRequestClose={() => setTagModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setTagModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>
{tagModalMode === 'folder' ? 'Créer un dossier' : 'Ajouter un tag'}
</Text>
<TextInput
style={styles.modalInput}
placeholder={tagModalMode === 'folder' ? 'Nom du dossier...' : 'Nom du tag...'}
placeholderTextColor="#999"
value={tagInput}
onChangeText={setTagInput}
autoFocus
returnKeyType="done"
onSubmitEditing={tagModalMode === 'folder' ? handleCreateFolderFromModal : handleAddTag}
/>
<View style={styles.modalActions}>
<TouchableOpacity style={styles.modalCancelBtn} onPress={() => setTagModalVisible(false)}>
<Text style={styles.modalCancelText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalConfirmBtn, !tagInput.trim() && styles.modalConfirmDisabled]}
onPress={tagModalMode === 'folder' ? handleCreateFolderFromModal : handleAddTag}
disabled={!tagInput.trim()}
>
<Text style={styles.modalConfirmText}>{tagModalMode === 'folder' ? 'Créer' : 'Ajouter'}</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal visible={moveModalVisible} transparent animationType="fade" onRequestClose={() => setMoveModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setMoveModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>Déplacer vers...</Text>
<TouchableOpacity
style={styles.folderOption}
onPress={() => handleMove(null)}
>
<MaterialIcons name="home" size={20} color="#666" />
<Text style={styles.folderOptionText}>Racine</Text>
</TouchableOpacity>
{(foldersData ?? []).map((folder) => (
<TouchableOpacity
key={folder.id}
style={styles.folderOption}
onPress={() => handleMove(folder.id)}
>
<MaterialIcons name="folder" size={20} color="#F57C00" />
<Text style={styles.folderOptionText}>{folder.name}</Text>
</TouchableOpacity>
))}
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<ConfirmModal
visible={confirmDeleteState !== null}
title="Supprimer"
message={confirmDeleteState?.message}
options={confirmDeleteState?.options ?? []}
onClose={() => setConfirmDeleteState(null)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
infoText: {
fontSize: 16,
color: '#666',
},
list: {
padding: 15,
paddingBottom: 80,
},
empty: {
paddingVertical: 60,
alignItems: 'center',
gap: 12,
},
emptyText: {
fontSize: 16,
color: '#999',
},
selectionBar: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingHorizontal: 16,
paddingVertical: 12,
},
selectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
cancelBtn: {
padding: 4,
},
selectionCount: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
deleteBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1.5,
borderColor: '#F44336',
gap: 6,
},
deleteText: {
fontSize: 15,
fontWeight: '600',
color: '#F44336',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
width: '80%',
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#333',
marginBottom: 16,
},
modalInput: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#333',
marginBottom: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalCancelBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
},
modalCancelText: {
fontSize: 15,
color: '#666',
},
modalConfirmBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#1976D2',
borderRadius: 8,
},
modalConfirmDisabled: {
backgroundColor: '#ccc',
},
modalConfirmText: {
fontSize: 15,
color: '#fff',
fontWeight: '600',
},
folderOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 8,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
folderOptionText: {
fontSize: 16,
color: '#333',
},
footer: {
paddingVertical: 20,
alignItems: 'center',
},
footerText: {
fontSize: 14,
color: '#999',
},
footerLink: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
});
-902
View File
@@ -1,902 +0,0 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { View, FlatList, StyleSheet, TouchableOpacity, Text, KeyboardAvoidingView, Platform, Keyboard, Modal, TextInput, ActivityIndicator } from 'react-native';
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 { useAddTags, useMoveResources, useFolders, useFiles, useFreeLocalSpace, useCreateFolder } from '../hooks/useFiles';
import { SelectionPanel } from '../components/SelectionPanel';
import { UnifiedFileItem, isFolder } from '../types';
import { SearchBar, SearchFilters, SortState } from '../components/SearchBar';
import { SortChips } from '../components/SortChips';
import { FileCard } from '../components/FileCard';
import { SettingsModal } from '../components/SettingsModal';
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
import { UploadModal } from '../components/UploadModal';
import { SyncStatusIcon } from '../components/SyncStatusIcon';
import { NetworkStatusBar } from '../components/NetworkStatusBar';
import { useSyncQueue } from '../hooks/useSyncQueue';
import { useUploadQueue } from '../hooks/useUploadQueue';
import { useAutoSync } from '../hooks/useAutoSync';
import { safDirectory, SyncMode, SyncGlobalMode } from '../services/safDirectory';
import { fileStore } from '../services/fileStore';
import { downloadRegistry } from '../services/downloadRegistry';
import { useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { useLocalFiles } from '../hooks/useLocalFiles';
import { deleteAsync } from 'expo-file-system/legacy';
import { useDebounce } from '../hooks/useDebounce';
const PAGE_SIZE = 100;
type RootStackParamList = {
Home: undefined;
Upload: undefined;
Scan: undefined;
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
FileEdit: { fileIds: string[] };
Folder: { folderId: string; folderName: string };
SyncDetail: undefined;
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
function parseBackendDate(dateStr: string): Date | null {
if (!dateStr) return null;
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/);
if (!match) return new Date(dateStr);
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]));
}
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;
if (filters.ocrText && file.ocrText?.toLowerCase().includes(q)) return true;
if (!filters.name && !filters.ocrText) {
if (file.name.toLowerCase().includes(q)) return true;
if (file.ocrText?.toLowerCase().includes(q)) return true;
}
return false;
}
function compareBySort(a: UnifiedFileItem, b: UnifiedFileItem, sort: SortState): number {
let cmp = 0;
if (sort.key === 'name') {
cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
} else if (sort.key === 'size') {
cmp = (a.size ?? 0) - (b.size ?? 0);
} else {
const da = parseBackendDate(a.createdAt);
const db = parseBackendDate(b.createdAt);
cmp = (da?.getTime() ?? 0) - (db?.getTime() ?? 0);
}
return sort.direction === 'asc' ? cmp : -cmp;
}
export function HomeScreen() {
const navigation = useNavigation<NavigationProp>();
const insets = useSafeAreaInsets();
const [page, setPage] = useState(1);
const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE);
const { pickAndScanRecursive, folders, refreshFolders, discovered } = useLocalFiles();
const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 250);
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
const toggleFilter = useCallback((key: keyof SearchFilters) => {
setFilters((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const [sort, setSort] = useState<SortState>({ key: 'date', direction: 'desc' });
const listRef = useRef<FlatList<UnifiedFileItem>>(null);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [tagModalVisible, setTagModalVisible] = useState(false);
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
const [tagInput, setTagInput] = useState('');
const addTags = useAddTags();
const moveFiles = useMoveResources();
const createFolder = useCreateFolder();
const { data: foldersData } = useFolders();
const [moveModalVisible, setMoveModalVisible] = useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [filterModalVisible, setFilterModalVisible] = useState(false);
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [globalSyncMode, setGlobalSyncMode] = useState<SyncGlobalMode>(() => safDirectory.getGlobalSyncMode());
const [globalSyncCellular, setGlobalSyncCellular] = useState(() => safDirectory.getGlobalSyncCellular());
const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null);
const [removeFolderConfirmId, setRemoveFolderConfirmId] = useState<string | null>(null);
const { pendingCount, isSyncing } = useSyncQueue();
const { tasks: uploadTasks } = useUploadQueue();
useAutoSync();
const loadingMoreRef = useRef(false);
const listLayoutHeightRef = useRef(0);
const listContentHeightRef = useRef(0);
const loadMore = useCallback(() => {
if (loadingMoreRef.current) return;
const total = data?.meta?.total ?? 0;
const loaded = data?.data?.length ?? 0;
if (loaded < total) {
loadingMoreRef.current = true;
setPage((p) => p + 1);
}
}, [data?.meta?.total, data?.data?.length]);
useEffect(() => {
if (!isFetching) {
loadingMoreRef.current = false;
}
}, [isFetching]);
const handleEndReached = useCallback(() => {
if (listLayoutHeightRef.current > 0 &&
listContentHeightRef.current > 0 &&
listContentHeightRef.current <= listLayoutHeightRef.current) {
return;
}
loadMore();
}, [loadMore]);
const totalFiles = data?.meta?.total ?? 0;
const loadedFiles = data?.data?.length ?? 0;
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
const isFiltering = !!debouncedSearch.trim();
useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardOpen(false));
return () => { show.remove(); hide.remove(); };
}, []);
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<NetworkStatusBar />
<SyncStatusIcon
isSyncing={isSyncing}
pendingCount={pendingCount}
isUploading={uploadTasks.some(t => t.status === 'uploading')}
uploadPendingCount={uploadTasks.filter(t => t.status === 'pending' || t.status === 'uploading').length}
onPress={() => navigation.navigate('SyncDetail')}
/>
<TouchableOpacity onPress={() => setUploadModalVisible(true)} style={{ padding: 8 }}>
<MaterialIcons name="add-circle-outline" size={22} color="#1976D2" />
</TouchableOpacity>
<TouchableOpacity onPress={() => setSettingsModalVisible(true)} style={{ marginRight: 4, padding: 8 }}>
<MaterialIcons name="settings" size={22} color="#666" />
</TouchableOpacity>
</View>
),
});
}, [navigation, pendingCount, isSyncing, uploadTasks]);
const selectionMode = selectedIds.size > 0;
const files = data?.data ?? [];
const filteredFiles = useMemo(
() => {
const search = debouncedSearch.trim();
if ( search ) {
return files.filter((f) => matchesQuery(f, debouncedSearch, filters));
}
return files;
},
[files, debouncedSearch, filters]
);
const uploadGhostItems = useMemo(() => {
return uploadTasks
.filter((t) => t.status === 'pending' || t.status === 'uploading')
.map((t) => ({
id: t.id,
name: t.file.name,
mimeType: t.file.type,
size: 0,
createdAt: new Date(t.createdAt).toISOString(),
source: 'local' as const,
syncStatus: 'local' as const,
localUri: t.file.uri,
tags: [],
isFolder: false,
isDeviceFile: false,
isUploading: true,
uploadProgress: t.progress,
uploadStatus: t.status,
}));
}, [uploadTasks]);
const sortedFiles = useMemo(() => {
const uploadedExistingIds = new Set(
filteredFiles.map((f) => f.localUri).filter(Boolean)
);
const ghosts = uploadGhostItems.filter(
(g) => g.localUri && !uploadedExistingIds.has(g.localUri)
);
const sorted = [...filteredFiles].sort((a, b) => compareBySort(a, b, sort));
return [...ghosts, ...sorted];
}, [filteredFiles, uploadGhostItems, sort]);
const displayedCount = sortedFiles.length;
const fileIdToIndex = useMemo(() => {
const map = new Map<string, number>();
sortedFiles.forEach((f, i) => map.set(f.id, i));
return map;
}, [sortedFiles]);
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
}, []);
const handleDelete = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
const hasSynced = ids.some((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`;
const options: ConfirmOption[] = [];
if (hasSynced) {
options.push({
label: 'Du device uniquement',
onPress: async () => {
const syncedIds = ids.filter((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
if (syncedIds.length > 0) {
await freeLocalSpace.mutateAsync(syncedIds);
}
setSelectedIds(new Set());
},
});
}
options.push({
label: 'Du device + serveur',
destructive: true,
onPress: async () => {
for (const id of ids) {
const f = files.find((fi) => fi.id === id);
if (f?.backendResourceId) {
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${f.backendResourceId}`);
fileStore.deleteByBackendId(f.backendResourceId);
} else {
fileStore.deleteById(id);
}
if (f?.localUri) {
try { await deleteAsync(f.localUri, { idempotent: true }); } catch {}
}
downloadRegistry.remove(id);
}
await queryClient.invalidateQueries({ queryKey: ['resources'] });
setSelectedIds(new Set());
},
});
options.push({ label: 'Annuler' });
setConfirmDeleteState({ message: `Supprimer ${label} ?`, options });
}, [selectedIds, files, freeLocalSpace, queryClient]);
const handleEdit = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
navigation.navigate('FileEdit', { fileIds: ids });
setSelectedIds(new Set());
}, [selectedIds, navigation]);
const openTagModal = useCallback((mode: 'tag' | 'folder') => {
setTagModalMode(mode);
setTagInput('');
setTagModalVisible(true);
}, []);
const handleAddTag = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
for (const id of ids) {
await addTags.mutateAsync({ fileId: id, tags: [name] });
}
setTagModalVisible(false);
setSelectedIds(new Set());
}, [tagInput, selectedIds, addTags]);
const handleCreateFolderFromModal = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
try {
const newFolder = await createFolder.mutateAsync({ name });
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: newFolder.id });
setTagModalVisible(false);
setSelectedIds(new Set());
navigation.navigate('Folder', { folderId: newFolder.id, folderName: newFolder.name });
} catch {}
}, [tagInput, selectedIds, createFolder, moveFiles, navigation]);
const handleMove = useCallback(async (folderId: string | null) => {
const ids = Array.from(selectedIds);
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: folderId });
setMoveModalVisible(false);
setSelectedIds(new Set());
}, [selectedIds, moveFiles]);
const handleToggleFolderVisibility = useCallback((folderId: string) => {
safDirectory.toggleVisibility(folderId);
refreshFolders();
}, [refreshFolders]);
const handleRemoveFolder = useCallback((folderId: string) => {
setRemoveFolderConfirmId(folderId);
}, []);
const handleRemoveFolderConfirm = useCallback(() => {
if (removeFolderConfirmId) {
safDirectory.removeFolder(removeFolderConfirmId);
refreshFolders();
}
setRemoveFolderConfirmId(null);
}, [removeFolderConfirmId, refreshFolders]);
const handleAddFolderRecursive = useCallback(async () => {
setSettingsModalVisible(false);
await pickAndScanRecursive();
}, [pickAndScanRecursive]);
const handleUpdateSyncMode = useCallback((folderId: string, mode: SyncMode) => {
safDirectory.updateSyncMode(folderId, mode);
refreshFolders();
}, [refreshFolders]);
const handleUpdateSyncCellular = useCallback((folderId: string, enabled: boolean) => {
safDirectory.updateSyncCellular(folderId, enabled);
refreshFolders();
}, [refreshFolders]);
const handleSetGlobalSyncMode = useCallback((mode: SyncGlobalMode) => {
safDirectory.setGlobalSyncMode(mode);
setGlobalSyncMode(mode);
}, []);
const handleSetGlobalSyncCellular = useCallback((enabled: boolean) => {
safDirectory.setGlobalSyncCellular(enabled);
setGlobalSyncCellular(enabled);
}, []);
const handleItemPress = useCallback((file: UnifiedFileItem) => {
if (selectionMode) {
toggleSelection(file.id);
} else if (isFolder(file)) {
navigation.navigate('Folder', { folderId: file.id, folderName: file.name });
} else {
const deviceFilesMap: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> = {};
for (const f of sortedFiles) {
if (f.isDeviceFile && f.localUri) {
deviceFilesMap[f.id] = { localUri: f.localUri, name: f.name, mimeType: f.mimeType, createdAt: f.createdAt };
}
}
navigation.navigate('FileDetail', {
fileIds: sortedFiles.map((f) => f.id),
initialIndex: fileIdToIndex.get(file.id) ?? 0,
deviceFiles: Object.keys(deviceFilesMap).length > 0 ? deviceFilesMap : undefined,
});
}
}, [selectionMode, toggleSelection, navigation, sortedFiles, fileIdToIndex]);
const handleItemLongPress = useCallback((file: UnifiedFileItem) => {
if (!selectionMode) {
toggleSelection(file.id);
}
}, [selectionMode, toggleSelection]);
const renderItem = useCallback(({ item }: { item: UnifiedFileItem }) => (
<FileCard
file={item}
selected={selectedIds.has(item.id)}
onPress={handleItemPress}
onLongPress={handleItemLongPress}
/>
), [selectedIds, handleItemPress, handleItemLongPress]);
if (isLoading) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Chargement...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Erreur de chargement</Text>
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 96 : 0}
>
{files.length === 0 && !searchQuery && (
<View style={styles.folderScanBanner}>
{folders.length > 0 ? (
<>
<MaterialIcons name="folder" size={24} color="#F57C00" />
<Text style={styles.folderScanText}>
{folders.length} dossier{folders.length > 1 ? 's' : ''} scanné{folders.length > 1 ? 's' : ''}
</Text>
<TouchableOpacity style={styles.permissionBtn} onPress={pickAndScanRecursive}>
<Text style={styles.permissionBtnText}>Tout scanner</Text>
</TouchableOpacity>
</>
) : (
<>
<MaterialIcons name="folder-open" size={24} color="#F57C00" />
<Text style={styles.folderScanText}>
Scanner un dossier de votre appareil
</Text>
<TouchableOpacity style={styles.permissionBtn} onPress={pickAndScanRecursive}>
<Text style={styles.permissionBtnText}>Choisir</Text>
</TouchableOpacity>
</>
)}
</View>
)}
<SortChips
sort={sort}
onSortChange={setSort}
/>
<View style={styles.listWrapper}>
<FlatList
ref={listRef}
data={sortedFiles}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onLayout={(e) => {
listLayoutHeightRef.current = e.nativeEvent.layout.height;
}}
onContentSizeChange={(w, h) => {
listContentHeightRef.current = h;
}}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
onScrollToIndexFailed={({ index, averageItemLength }) => {
listRef.current?.scrollToOffset({
offset: Math.max(0, averageItemLength * index),
animated: true,
});
setTimeout(() => {
listRef.current?.scrollToIndex({ index, animated: true, viewPosition: 0 });
}, 150);
}}
ListFooterComponent={
hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore} disabled={isFetching}>
{isFetching ? (
<ActivityIndicator size="small" color="#1976D2" />
) : (
<Text style={styles.loadMoreText}>
Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`})
</Text>
)}
</TouchableOpacity>
) : displayedCount > 0 ? (
<Text style={styles.loadedAllText}>{displayedCount} fichier{displayedCount > 1 ? 's' : ''}</Text>
) : null
}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>
{debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'}
</Text>
</View>
}
renderItem={renderItem}
/>
</View>
{!selectionMode && (
<SearchBar
query={searchQuery}
onQueryChange={setSearchQuery}
onClear={() => setSearchQuery('')}
filters={filters}
onFiltersChange={setFilters}
sort={sort}
onSettingsPress={() => setFilterModalVisible(true)}
bottomPadding={keyboardOpen ? insets.bottom+8 : 0}
/>
)}
{selectionMode ? (
<SelectionPanel
selectedCount={selectedIds.size}
onClose={clearSelection}
onDelete={handleDelete}
onEdit={handleEdit}
onTags={() => openTagModal('tag')}
onFolder={() => openTagModal('folder')}
onMove={() => setMoveModalVisible(true)}
insetsBottom={insets.bottom}
/>
) : (
<View style={styles.bottomNav}>
<TouchableOpacity style={styles.navButton} onPress={() => {}}>
<MaterialIcons name="home" size={24} color="#1976D2" />
<Text style={styles.navText}>Accueil</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.navButton}
onPress={() => navigation.navigate('Scan')}
>
<MaterialIcons name="document-scanner" size={24} color="#1976D2" />
<Text style={styles.navText}>Scan</Text>
</TouchableOpacity>
</View>
)}
<Modal visible={tagModalVisible} transparent animationType="fade" onRequestClose={() => setTagModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setTagModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>
{tagModalMode === 'folder' ? 'Créer un dossier' : 'Ajouter un tag'}
</Text>
<TextInput
style={styles.modalInput}
placeholder={tagModalMode === 'folder' ? 'Nom du dossier...' : 'Nom du tag...'}
placeholderTextColor="#999"
value={tagInput}
onChangeText={setTagInput}
autoFocus
returnKeyType="done"
onSubmitEditing={handleAddTag}
/>
<View style={styles.modalActions}>
<TouchableOpacity style={styles.modalCancelBtn} onPress={() => setTagModalVisible(false)}>
<Text style={styles.modalCancelText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalConfirmBtn, !tagInput.trim() && styles.modalConfirmDisabled]}
onPress={tagModalMode === 'folder' ? handleCreateFolderFromModal : handleAddTag}
disabled={!tagInput.trim()}
>
<Text style={styles.modalConfirmText}>{tagModalMode === 'folder' ? 'Créer' : 'Ajouter'}</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal visible={moveModalVisible} transparent animationType="fade" onRequestClose={() => setMoveModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setMoveModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>Déplacer vers...</Text>
<TouchableOpacity
style={styles.folderOption}
onPress={() => handleMove(null)}
>
<MaterialIcons name="home" size={20} color="#666" />
<Text style={styles.folderOptionText}>Racine</Text>
</TouchableOpacity>
{(foldersData ?? []).map((folder) => (
<TouchableOpacity
key={folder.id}
style={styles.folderOption}
onPress={() => handleMove(folder.id)}
>
<MaterialIcons name="folder" size={20} color="#F57C00" />
<Text style={styles.folderOptionText}>{folder.name}</Text>
</TouchableOpacity>
))}
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal visible={filterModalVisible} transparent animationType="fade" onRequestClose={() => setFilterModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setFilterModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>Rechercher dans</Text>
<TouchableOpacity
style={styles.folderOption}
onPress={() => toggleFilter('name')}
>
<MaterialIcons name="drive-file-rename-outline" size={20} color="#666" />
<Text style={styles.folderOptionText}>Nom</Text>
<View style={styles.filterCheck}>
{filters.name && <MaterialIcons name="check" size={18} color="#1976D2" />}
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.folderOption}
onPress={() => toggleFilter('ocrText')}
>
<MaterialIcons name="document-scanner" size={20} color="#666" />
<Text style={styles.folderOptionText}>Texte OCR</Text>
<View style={styles.filterCheck}>
{filters.ocrText && <MaterialIcons name="check" size={18} color="#1976D2" />}
</View>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<UploadModal
visible={uploadModalVisible}
onClose={() => setUploadModalVisible(false)}
/>
<SettingsModal
visible={settingsModalVisible}
onClose={() => setSettingsModalVisible(false)}
folders={folders}
onToggleVisibility={handleToggleFolderVisibility}
onRemoveFolder={handleRemoveFolder}
onAddFolderRecursive={handleAddFolderRecursive}
onUpdateSyncMode={handleUpdateSyncMode}
onUpdateSyncCellular={handleUpdateSyncCellular}
globalSyncMode={globalSyncMode}
onSetGlobalSyncMode={handleSetGlobalSyncMode}
globalSyncCellular={globalSyncCellular}
onSetGlobalSyncCellular={handleSetGlobalSyncCellular}
/>
<ConfirmModal
visible={confirmDeleteState !== null}
title="Supprimer"
message={confirmDeleteState?.message}
options={confirmDeleteState?.options ?? []}
onClose={() => setConfirmDeleteState(null)}
/>
<ConfirmModal
visible={removeFolderConfirmId !== null}
title="Supprimer le dossier"
message="Le dossier sera retiré de la liste. Les fichiers resteront sur votre appareil."
options={[
{ label: 'Annuler' },
{ label: 'Supprimer', destructive: true, onPress: handleRemoveFolderConfirm },
]}
onClose={() => setRemoveFolderConfirmId(null)}
/>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
loadMoreBtn: {
alignItems: 'center',
paddingVertical: 16,
},
loadMoreText: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
loadedAllText: {
textAlign: 'center',
fontSize: 13,
color: '#999',
paddingVertical: 12,
},
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
permissionBtn: {
backgroundColor: '#1976D2',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
permissionBtnText: {
fontSize: 14,
color: '#fff',
fontWeight: '600',
},
folderScanBanner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF3E0',
paddingHorizontal: 16,
paddingVertical: 14,
gap: 10,
},
folderScanText: {
flex: 1,
fontSize: 14,
color: '#333',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
infoText: {
fontSize: 16,
color: '#666',
},
listWrapper: {
flex: 1,
},
list: {
paddingHorizontal: 15,
paddingTop: 10,
paddingBottom: 80,
},
empty: {
paddingVertical: 60,
alignItems: 'center',
},
emptyText: {
fontSize: 16,
color: '#999',
},
bottomNav: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: 12,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
navButton: {
alignItems: 'center',
padding: 8,
gap: 4,
},
navText: {
fontSize: 16,
color: '#1976D2',
},
selectionBar: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingHorizontal: 16,
paddingTop: 12,
},
selectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
cancelBtn: {
padding: 4,
},
selectionCount: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
selectAllBtn: {
paddingHorizontal: 8,
paddingVertical: 4,
},
selectAllText: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
selectionActions: {
flexDirection: 'row',
gap: 8,
paddingVertical: 4,
},
selectionChip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
gap: 6,
},
chipText: {
fontSize: 13,
fontWeight: '600',
},
deleteChip: { backgroundColor: '#FFEBEE' },
editChip: { backgroundColor: '#E3F2FD' },
tagChip: { backgroundColor: '#F3E5F5' },
folderChip: { backgroundColor: '#FFF3E0' },
moveChip: { backgroundColor: '#E0F2F1' },
folderOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 8,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
folderOptionText: {
fontSize: 16,
color: '#333',
flex: 1,
},
filterCheck: {
width: 24,
alignItems: 'flex-end',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
width: '80%',
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#333',
marginBottom: 16,
},
modalInput: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#333',
marginBottom: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalCancelBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
},
modalCancelText: {
fontSize: 15,
color: '#666',
},
modalConfirmBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#1976D2',
borderRadius: 8,
},
modalConfirmDisabled: {
backgroundColor: '#ccc',
},
modalConfirmText: {
fontSize: 15,
color: '#fff',
fontWeight: '600',
},
});
-143
View File
@@ -1,143 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useAuth } from '../contexts/AuthContext';
export function LoginScreen({ navigation }: any) {
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleLogin() {
if (!username.trim() || !password) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
setLoading(true);
try {
await login(username.trim(), password);
} catch (error: any) {
Alert.alert('Erreur', error.message || 'Connexion échouée');
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.inner}>
<Text style={styles.title}>Dot.</Text>
<Text style={styles.subtitle}>Connectez-vous à votre compte</Text>
<TextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor="#999"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
secureTextEntry
editable={!loading}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Se connecter</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.navigate('Register')}
disabled={loading}
>
<Text style={styles.linkText}>Pas de compte ? S'inscrire</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
inner: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 32,
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 32,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 16,
},
button: {
backgroundColor: '#000',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#000',
fontSize: 14,
},
});
-297
View File
@@ -1,297 +0,0 @@
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
} from 'react-native';
import { useNavigation, CommonActions } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION } from '../config/onboarding';
import { onboardingStorage } from '../services/onboardingStorage';
import { safDirectory, StoredFolder } from '../services/safDirectory';
import * as FileSystem from 'expo-file-system/legacy';
function SelectFoldersStep({
selectedFolders,
onAddFolder,
onRemoveFolder,
}: {
selectedFolders: StoredFolder[];
onAddFolder: () => void;
onRemoveFolder: (folder: StoredFolder) => void;
}) {
return (
<View style={styles.folderStepContent}>
<View style={styles.iconContainer}>
<MaterialIcons name="create-new-folder" size={64} color="#1976D2" />
</View>
<Text style={styles.title}>Ajoutez vos dossiers</Text>
<Text style={styles.description}>
Sélectionnez les dossiers que vous souhaitez synchroniser avec Dot.
</Text>
{selectedFolders.length > 0 && (
<View style={styles.folderList}>
{selectedFolders.map((f) => (
<View key={f.id} style={styles.selectedFolderRow}>
<MaterialIcons name="folder" size={20} color="#F57C00" />
<Text style={styles.selectedFolderName} numberOfLines={1}>
{f.name}
</Text>
<TouchableOpacity
onPress={() => onRemoveFolder(f)}
style={styles.removeBtn}
>
<MaterialIcons name="close" size={18} color="#E53935" />
</TouchableOpacity>
</View>
))}
</View>
)}
<TouchableOpacity style={styles.actionBtn} onPress={onAddFolder}>
<MaterialIcons name="add" size={20} color="#fff" />
<Text style={styles.actionBtnText}>Ajouter un dossier</Text>
</TouchableOpacity>
</View>
);
}
export function OnboardingScreen() {
const navigation = useNavigation();
const [currentIndex, setCurrentIndex] = useState(0);
const [selectedFolders, setSelectedFolders] = useState<StoredFolder[]>([]);
const pendingSteps = onboardingStorage.getPendingSteps();
const step = pendingSteps[currentIndex];
const complete = useCallback(() => {
onboardingStorage.setCompletedVersion(CURRENT_ONBOARDING_VERSION);
navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'Home' }] }));
}, [navigation]);
const handlePickDirectory = useCallback(async () => {
try {
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!result.granted) return;
const dirUri = result.directoryUri;
const parts = dirUri.split('/');
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
const folder = safDirectory.addFolder(dirUri, dirName);
setSelectedFolders((prev) => [...prev, folder]);
} catch (err) {
console.error('[Onboarding] pickDirectory error:', err);
}
}, []);
const handleRemoveFolder = useCallback((folder: StoredFolder) => {
safDirectory.removeFolder(folder.id);
setSelectedFolders((prev) => prev.filter((f) => f.id !== folder.id));
}, []);
const handleNext = useCallback(async () => {
if (!step) return;
onboardingStorage.markStepSeen(step.id);
if (currentIndex < pendingSteps.length - 1) {
setCurrentIndex(currentIndex + 1);
setSelectedFolders([]);
} else {
complete();
}
}, [step, currentIndex, pendingSteps.length, complete]);
const handleSkip = useCallback(() => {
complete();
}, [complete]);
useEffect(() => {
if (!step) {
complete();
}
}, [step, complete]);
if (!step) {
return null;
}
const isFolderStep = step.action?.type === 'pick_directory';
return (
<View style={styles.container}>
<View style={styles.skipContainer}>
<TouchableOpacity onPress={handleSkip}>
<Text style={styles.skipText}>Passer</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.scrollContent} contentContainerStyle={styles.scrollInner}>
{isFolderStep ? (
<SelectFoldersStep
selectedFolders={selectedFolders}
onAddFolder={handlePickDirectory}
onRemoveFolder={handleRemoveFolder}
/>
) : (
<View style={styles.welcomeContent}>
<View style={styles.iconContainer}>
<MaterialIcons name="waving-hand" size={64} color="#1976D2" />
</View>
<Text style={styles.title}>{step.title}</Text>
<Text style={styles.description}>{step.description}</Text>
</View>
)}
</ScrollView>
<View style={styles.footer}>
<View style={styles.dots}>
{pendingSteps.map((_, i) => (
<View
key={i}
style={[styles.dot, i === currentIndex && styles.dotActive]}
/>
))}
</View>
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
<Text style={styles.nextBtnText}>
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
</Text>
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
skipContainer: {
alignItems: 'flex-end',
paddingHorizontal: 20,
paddingTop: 60,
},
skipText: {
fontSize: 16,
color: '#999',
},
scrollContent: {
flex: 1,
},
scrollInner: {
flexGrow: 1,
},
welcomeContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
},
folderStepContent: {
flex: 1,
alignItems: 'center',
paddingHorizontal: 40,
paddingTop: 40,
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#E3F2FD',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 40,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#333',
textAlign: 'center',
marginBottom: 16,
},
description: {
fontSize: 16,
color: '#666',
textAlign: 'center',
lineHeight: 24,
marginBottom: 20,
},
folderList: {
width: '100%',
marginBottom: 16,
},
selectedFolderRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fafafa',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 8,
gap: 10,
},
selectedFolderName: {
flex: 1,
fontSize: 15,
color: '#333',
},
removeBtn: {
padding: 4,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F57C00',
borderRadius: 12,
paddingHorizontal: 24,
paddingVertical: 14,
gap: 10,
width: '100%',
},
actionBtnText: {
fontSize: 16,
color: '#fff',
fontWeight: '600',
},
footer: {
paddingHorizontal: 40,
paddingBottom: 60,
alignItems: 'center',
gap: 24,
},
dots: {
flexDirection: 'row',
gap: 8,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#ddd',
},
dotActive: {
backgroundColor: '#1976D2',
width: 24,
},
nextBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1976D2',
borderRadius: 12,
paddingHorizontal: 32,
paddingVertical: 14,
gap: 8,
},
nextBtnText: {
fontSize: 16,
color: '#fff',
fontWeight: '600',
},
});
-350
View File
@@ -1,350 +0,0 @@
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, TextInput, Alert, ActivityIndicator, Modal, Dimensions } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { useBatchStore } from '../hooks/useBatchStore';
import { usePdfGeneration } from '../hooks/usePdfGeneration';
import { useUpload } from '../hooks/useUpload';
import { CapturedPhoto } from '../types';
const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS;
type PendingReviewRouteParams = {
PendingReview: { batchId: string; photoIds: string[] };
};
export function PendingReviewScreen() {
const route = useRoute<RouteProp<PendingReviewRouteParams, 'PendingReview'>>();
const navigation = useNavigation();
const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore();
const { generatePdf, generating, progress } = usePdfGeneration();
const upload = useUpload();
const batch = getBatch(route.params.batchId);
const initialPhotos = (batch?.photos ?? []).filter((p) =>
route.params.photoIds.includes(p.id)
);
const [photos, setPhotos] = useState<CapturedPhoto[]>(initialPhotos);
const [selectedSet, setSelectedSet] = useState<Set<string>>(new Set(initialPhotos.map((p) => p.id)));
const [batchTags, setBatchTags] = useState<string[]>(batch?.tags ?? []);
const [tagInput, setTagInput] = useState('');
const [previewUri, setPreviewUri] = useState<string | null>(null);
const orderedPhotos = photos.filter((p) => selectedSet.has(p.id));
const togglePhoto = (id: string) => {
setSelectedSet((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddTag = () => {
const tag = tagInput.trim().toLowerCase();
if (!tag || batchTags.includes(tag)) return;
setBatchTags((prev) => [...prev, tag]);
addTagToBatch(route.params.batchId, tag);
setTagInput('');
};
const handleRemoveTag = (tag: string) => {
setBatchTags((prev) => prev.filter((t) => t !== tag));
removeTagFromBatch(route.params.batchId, tag);
};
const handleFinalize = async () => {
if (orderedPhotos.length === 0) {
Alert.alert('Aucune photo', 'Sélectionnez au moins une photo');
return;
}
const pdfUri = await generatePdf(orderedPhotos.map((p) => ({ uri: p.uri })));
if (!pdfUri) {
Alert.alert('Erreur', 'Échec de la génération du PDF');
return;
}
Alert.alert(
'PDF généré',
'Voulez-vous uploader le fichier ?',
[
{ text: 'Annuler', style: 'cancel', onPress: () => navigation.goBack() },
{
text: 'Uploader',
onPress: async () => {
try {
const pdfName = `${batch?.name ?? 'document'}.pdf`;
const pdfUriClean = pdfUri.startsWith('file://') ? pdfUri : 'file://' + pdfUri;
await upload.mutateAsync([
{ uri: pdfUriClean, type: 'application/pdf', name: pdfName },
]);
Alert.alert('Succès', 'PDF uploadé avec succès', [
{ text: 'OK', onPress: () => navigation.navigate('Home' as never) },
]);
} catch (e: any) {
const msg = e?.message || e?.toString() || "Erreur inconnue";
Alert.alert('Erreur', `Échec de l'upload du PDF: ${msg}`);
}
},
},
]
);
};
if (!batch) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Lot introuvable</Text>
</View>
);
}
const renderPhoto = ({ item, index }: { item: CapturedPhoto; index: number }) => {
const isSelected = selectedSet.has(item.id);
const pageNumber = isSelected
? orderedPhotos.findIndex((p) => p.id === item.id) + 1
: null;
return (
<TouchableOpacity
style={styles.gridItem}
onPress={() => togglePhoto(item.id)}
onLongPress={() => setPreviewUri(item.uri)}
>
<Image source={{ uri: item.uri }} style={styles.thumb} />
{isSelected && (
<View style={styles.selectedOverlay}>
<Text style={styles.pageNumber}>{pageNumber}</Text>
</View>
)}
</TouchableOpacity>
);
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Réorganiser les photos</Text>
<Text style={styles.subtitle}>
{orderedPhotos.length}/{photos.length} sélectionnée{orderedPhotos.length > 1 ? 's' : ''}
</Text>
</View>
<FlatList
data={photos}
numColumns={NUM_COLUMNS}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
renderItem={renderPhoto}
/>
<View style={styles.tagSection}>
<Text style={styles.sectionTitle}>Tags</Text>
<View style={styles.tagRow}>
{batchTags.map((tag) => (
<TouchableOpacity key={tag} style={styles.tagChip} onPress={() => handleRemoveTag(tag)}>
<Text style={styles.tagText}>{tag} </Text>
</TouchableOpacity>
))}
</View>
<View style={styles.tagInputRow}>
<TextInput
style={styles.tagInput}
placeholder="Ajouter un tag..."
value={tagInput}
onChangeText={setTagInput}
onSubmitEditing={handleAddTag}
returnKeyType="done"
/>
<TouchableOpacity style={styles.tagAddBtn} onPress={handleAddTag}>
<Text style={styles.tagAddText}>+</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.footer}>
{generating && (
<View style={styles.progressRow}>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.progressText}>Génération du PDF... {progress}%</Text>
</View>
)}
<TouchableOpacity
style={[styles.finalizeBtn, generating && styles.finalizeBtnDisabled]}
onPress={handleFinalize}
disabled={generating}
>
{generating ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.finalizeText}>📄 Finaliser et uploader le PDF</Text>
)}
</TouchableOpacity>
</View>
<Modal visible={previewUri !== null} transparent animationType="fade" onRequestClose={() => setPreviewUri(null)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setPreviewUri(null)}>
{previewUri && (
<Image source={{ uri: previewUri }} style={styles.previewImage} resizeMode="contain" />
)}
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: '#666',
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#666',
},
grid: {
padding: 16,
},
gridRow: {
gap: 6,
},
gridItem: {
width: ITEM_SIZE,
height: ITEM_SIZE,
borderRadius: 6,
overflow: 'hidden',
backgroundColor: '#f0f0f0',
marginBottom: 6,
},
thumb: {
width: '100%',
height: '100%',
},
selectedOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(25,118,210,0.35)',
justifyContent: 'center',
alignItems: 'center',
},
pageNumber: {
color: '#fff',
fontSize: 28,
fontWeight: '800',
},
tagSection: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginBottom: 8,
},
tagChip: {
backgroundColor: '#E3F2FD',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
tagText: {
fontSize: 13,
color: '#1976D2',
},
tagInputRow: {
flexDirection: 'row',
gap: 8,
},
tagInput: {
flex: 1,
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
padding: 10,
fontSize: 14,
},
tagAddBtn: {
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
},
tagAddText: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
},
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 8,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
progressText: {
fontSize: 14,
color: '#666',
},
finalizeBtn: {
backgroundColor: '#1976D2',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
},
finalizeBtnDisabled: {
opacity: 0.6,
},
finalizeText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.9)',
justifyContent: 'center',
alignItems: 'center',
},
previewImage: {
width: SCREEN_WIDTH * 0.95,
height: '80%',
},
});
-164
View File
@@ -1,164 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useAuth } from '../contexts/AuthContext';
export function RegisterScreen({ navigation }: any) {
const { register } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleRegister() {
if (!username.trim() || !password || !confirmPassword) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
if (password !== confirmPassword) {
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
return;
}
if (password.length < 8) {
Alert.alert('Erreur', 'Le mot de passe doit contenir au moins 8 caractères');
return;
}
setLoading(true);
try {
await register(username.trim(), password);
} catch (error: any) {
Alert.alert('Erreur', error.message || "Inscription échouée");
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.inner}>
<Text style={styles.title}>Dot.</Text>
<Text style={styles.subtitle}>Créez votre compte</Text>
<TextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor="#999"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
secureTextEntry
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Confirmer le mot de passe"
placeholderTextColor="#999"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
editable={!loading}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>S'inscrire</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.navigate('Login')}
disabled={loading}
>
<Text style={styles.linkText}>Déjà un compte ? Se connecter</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
inner: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 32,
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 32,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 16,
},
button: {
backgroundColor: '#000',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#000',
fontSize: 14,
},
});
-273
View File
@@ -1,273 +0,0 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Camera } from 'react-native-vision-camera';
import { useCameraCapture } from '../hooks/useCameraCapture';
import { UploadProgress } from '../components/UploadProgress';
import { PhotoThumbnailStrip } from '../components/PhotoThumbnailStrip';
import { useBatchStore } from '../hooks/useBatchStore';
type RootStackParamList = {
Home: undefined;
Upload: undefined;
Scan: undefined;
Search: undefined;
BatchReview: { batchId: string };
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export function ScanScreen() {
const navigation = useNavigation<NavigationProp>();
const {
cameraRef,
hasPermission,
requestPermission,
device,
photoOutput,
capturePhoto,
captureStatus,
captureError,
isActive,
torchMode,
toggleTorch,
onStarted,
onStopped,
capturedPhotos,
removeCapturedPhoto,
clearCapturedPhotos,
capturedCount,
} = useCameraCapture();
const { saveBatch } = useBatchStore();
useEffect(() => {
if (!hasPermission) {
requestPermission();
}
}, [hasPermission, requestPermission]);
const statusToUploadProgress: Record<string, 'idle' | 'uploading' | 'processing' | 'success' | 'error'> = {
idle: 'idle',
capturing: 'uploading',
uploading: 'uploading',
success: 'success',
error: 'error',
};
const finishBatch = () => {
if (capturedPhotos.length === 0) return;
const batchId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const now = new Date();
const name = `Lot du ${now.toLocaleDateString('fr-FR')} ${now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
saveBatch({
id: batchId,
name,
createdAt: now.toISOString(),
photos: capturedPhotos,
tags: [],
});
clearCapturedPhotos();
navigation.navigate('BatchReview', { batchId });
};
if (!hasPermission) {
return (
<View style={styles.center}>
<Text style={styles.permissionText}>Permission caméra requise</Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}>Accorder l'accès</Text>
</TouchableOpacity>
</View>
);
}
if (!device) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1976D2" />
<Text style={styles.loadingText}>Caméra initialisation...</Text>
</View>
);
}
return (
<View style={styles.container}>
<Camera
ref={cameraRef}
style={StyleSheet.absoluteFill}
isActive={isActive}
device={device}
outputs={[photoOutput]}
onStarted={onStarted}
onStopped={onStopped}
/>
<View style={styles.viewfinderOverlay} pointerEvents="none">
<View style={styles.viewfinderFrame} />
</View>
<View style={styles.topOverlay}>
<UploadProgress
status={statusToUploadProgress[captureStatus]}
error={captureError}
totalCount={1}
uploadedCount={captureStatus === 'success' ? 1 : 0}
/>
{capturedCount > 0 && (
<View style={styles.batchInfo}>
<Text style={styles.batchInfoText}>{capturedCount} photo{capturedCount > 1 ? 's' : ''} prise{capturedCount > 1 ? 's' : ''}</Text>
</View>
)}
</View>
<PhotoThumbnailStrip photos={capturedPhotos} onRemove={removeCapturedPhoto} />
<View style={styles.bottomBar}>
<TouchableOpacity style={styles.torchButton} onPress={toggleTorch}>
<Text style={styles.torchIcon}>{torchMode === 'on' ? '🔦' : '💡'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.captureButton, (captureStatus === 'capturing' || captureStatus === 'uploading') && styles.captureButtonDisabled]}
onPress={capturePhoto}
disabled={captureStatus === 'capturing' || captureStatus === 'uploading'}
>
<View style={styles.captureInner} />
</TouchableOpacity>
{capturedCount > 0 ? (
<TouchableOpacity style={styles.finishButton} onPress={finishBatch}>
<Text style={styles.finishText}>✓</Text>
</TouchableOpacity>
) : (
<View style={styles.torchButton} />
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
padding: 24,
},
permissionText: {
fontSize: 18,
color: '#fff',
textAlign: 'center',
marginBottom: 24,
},
permissionButton: {
backgroundColor: '#1976D2',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 8,
},
permissionButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
loadingText: {
fontSize: 16,
color: '#fff',
marginTop: 16,
},
viewfinderOverlay: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
},
viewfinderFrame: {
width: '85%',
maxWidth: 400,
aspectRatio: 3 / 4,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.6)',
borderRadius: 12,
},
topOverlay: {
position: 'absolute',
top: 60,
left: 16,
right: 16,
},
batchInfo: {
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 8,
padding: 8,
marginTop: 8,
alignItems: 'center',
},
batchInfoText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
bottomBar: {
position: 'absolute',
bottom: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
paddingHorizontal: 32,
},
torchButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center',
alignItems: 'center',
},
torchIcon: {
fontSize: 24,
},
captureButton: {
width: 76,
height: 76,
borderRadius: 38,
borderWidth: 4,
borderColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
},
captureButtonDisabled: {
opacity: 0.5,
},
captureInner: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#fff',
},
finishButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#4CAF50',
justifyContent: 'center',
alignItems: 'center',
},
finishText: {
color: '#fff',
fontSize: 22,
fontWeight: '700',
},
});
-62
View File
@@ -1,62 +0,0 @@
import React, { useState } from 'react';
import { View, TextInput, FlatList, StyleSheet, Text } from 'react-native';
import { useSearch } from '../hooks/useSearch';
import { FileCard } from '../components/FileCard';
import { FileItem } from '../types';
export function SearchScreen() {
const [query, setQuery] = useState('');
const { data, isLoading } = useSearch(query);
const renderItem = ({ item }: { item: FileItem }) => (
<FileCard
file={item}
onPress={(file) => console.log('File pressed:', file.id)}
/>
);
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="Rechercher un fichier..."
value={query}
onChangeText={setQuery}
autoCapitalize="none"
/>
{isLoading && <Text style={styles.loading}>Recherche en cours...</Text>}
<FlatList
data={data || []}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
input: {
backgroundColor: '#fff',
padding: 12,
margin: 16,
borderRadius: 8,
fontSize: 16,
borderWidth: 1,
borderColor: '#e0e0e0',
},
loading: {
textAlign: 'center',
color: '#666',
marginBottom: 8,
},
list: {
padding: 16,
},
});
-490
View File
@@ -1,490 +0,0 @@
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Alert,
Animated,
Easing,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { fileStore, FileRecord } from '../services/fileStore';
import { useSyncQueue, useSyncProgress } from '../hooks/useSyncQueue';
import { useAutoSync } from '../hooks/useAutoSync';
import { useSyncPush } from '../hooks/useSyncPush';
import { useUploadQueue } from '../hooks/useUploadQueue';
import { UploadTask } from '../services/uploadQueue';
function uploadStatusIcon(task: UploadTask) {
switch (task.status) {
case 'pending':
return <MaterialIcons name="schedule" size={20} color="#FFA000" />;
case 'uploading':
return <ActivityIndicator size="small" color="#1976D2" />;
case 'done':
return <MaterialIcons name="check-circle" size={20} color="#4CAF50" />;
case 'error':
return <MaterialIcons name="error" size={20} color="#E53935" />;
}
}
function SpinningSyncIcon({ size, color }: { size: number; color: string }) {
const spin = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.timing(spin, {
toValue: 1,
duration: 1200,
easing: Easing.linear,
useNativeDriver: true,
})
);
animation.start();
return () => animation.stop();
}, [spin]);
const rotate = spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
return (
<Animated.View style={{ transform: [{ rotate }] }}>
<MaterialIcons name="sync" size={size} color={color} />
</Animated.View>
);
}
type PendingFile = { id: string; name: string };
const PendingSyncCard = React.memo(function PendingSyncCard({
syncing,
pendingCount,
shortList,
onSyncPress,
onCancel,
}: {
syncing: boolean;
pendingCount: number;
shortList: PendingFile[] | null;
onSyncPress: () => void;
onCancel: () => void;
}) {
return (
<View style={styles.pendingCard}>
<TouchableOpacity
style={styles.pendingCardTop}
onPress={syncing ? undefined : onSyncPress}
disabled={syncing}
activeOpacity={0.8}
>
<View style={styles.pendingCardIcon}>
{syncing ? (
<SpinningSyncIcon size={28} color="#1976D2" />
) : (
<MaterialIcons name="cloud-upload" size={28} color="#1976D2" />
)}
</View>
<View style={styles.pendingCardInfo}>
<Text style={styles.pendingCardTitle}>
{syncing
? 'Synchronisation en cours...'
: pendingCount > 1
? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés`
: 'Vous avez 1 fichier local pouvant être synchronisé'}
</Text>
<Text style={styles.pendingCardSubtitle}>
{syncing
? shortList && shortList[0]
? `En cours : ${shortList[0].name}`
: 'Synchronisation en cours...'
: 'Appuyer maintenant pour les synchroniser'}
</Text>
</View>
{!syncing && <MaterialIcons name="chevron-right" size={24} color="#999" />}
</TouchableOpacity>
{syncing && shortList && shortList.length > 0 && (
<View style={styles.pendingList}>
{shortList.map((f, i) => (
<View key={f.id} style={styles.pendingRow}>
{i === 0 ? (
<SpinningSyncIcon size={16} color="#1976D2" />
) : (
<MaterialIcons name="schedule" size={16} color="#FFA000" />
)}
<Text
style={[styles.pendingRowText, i === 0 && styles.pendingRowActive]}
numberOfLines={1}
>
{f.name}
</Text>
</View>
))}
</View>
)}
{syncing && (
<TouchableOpacity style={styles.stopBtn} onPress={onCancel} activeOpacity={0.8}>
<MaterialIcons name="stop-circle" size={18} color="#fff" />
<Text style={styles.stopBtnText}>Arrêter la synchronisation</Text>
</TouchableOpacity>
)}
</View>
);
});
export function SyncDetailScreen() {
const { pendingCount, isSyncing, refresh } = useSyncQueue();
const { syncProgress } = useSyncProgress();
const { syncManually, cancelSync } = useAutoSync();
const { push } = useSyncPush();
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
const [listVersion, setListVersion] = useState(0);
const bumpList = useCallback(() => setListVersion((v) => v + 1), []);
useEffect(() => {
const interval = setInterval(bumpList, 5000);
return () => clearInterval(interval);
}, [bumpList]);
const errorFiles = useMemo(() => {
return fileStore.getErrorFiles();
}, [listVersion]);
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
const handleSyncAll = useCallback(async () => {
for (const f of fileStore.getErrorFiles()) {
fileStore.resetSyncError(f.id);
}
await syncManually();
try {
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
const serverId = await getStoredDeviceServerId();
if (serverId) {
await push(serverId);
}
} catch { }
refresh();
bumpList();
}, [syncManually, push, refresh, bumpList]);
const handleSyncButtonPress = useCallback(async () => {
if (uploadErrors.length > 0) {
retryAll();
}
await handleSyncAll();
}, [uploadErrors.length, retryAll, handleSyncAll]);
const handleErrorFilePress = useCallback((file: FileRecord) => {
Alert.alert(
'Fichier en erreur',
'Réessayer la synchronisation de ce fichier ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Réessayer',
onPress: () => {
fileStore.resetSyncError(file.id);
syncManually().finally(() => {
refresh();
bumpList();
});
},
},
],
);
}, [syncManually, refresh, bumpList]);
const handleTaskPress = useCallback((task: UploadTask) => {
if (task.status !== 'error') return;
Alert.alert(
'Erreur d\'upload',
task.error || 'Erreur inconnue',
[
{ text: 'Annuler', style: 'cancel' },
{ text: 'Réessayer', onPress: () => retry(task.id) },
],
);
}, [retry]);
const syncing = !!syncProgress;
const hasUploads = uploadTasks.length > 0;
const hasPending = pendingCount > 0 || syncing;
const hasErrors = errorFiles.length > 0;
const hasContent = hasUploads || hasPending || hasErrors;
const shortList = useMemo(() => {
if (!syncProgress) return null;
const { files, currentIndex } = syncProgress;
const start = Math.max(0, currentIndex);
return files.slice(start, start + 5);
}, [syncProgress]);
return (
<View style={styles.container}>
{!hasContent ? (
<View style={styles.empty}>
<MaterialIcons name="cloud-done" size={48} color="#4CAF50" />
<Text style={styles.emptyTitle}>Tout est synchronisé</Text>
<Text style={styles.emptySubtitle}>
Aucun fichier en attente
</Text>
</View>
) : (
<FlatList
data={[
...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []),
...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })),
...(hasErrors ? [{ type: 'section', label: 'Fichiers en erreur' } as const] : []),
...errorFiles.map((f) => ({ type: 'error' as const, data: f })),
...(hasPending ? [{ type: 'pendingCard' } as const] : []),
]}
keyExtractor={(item) =>
item.type === 'section' || item.type === 'pendingCard'
? item.type === 'pendingCard'
? 'pending-card'
: item.label
: item.data.id
}
extraData={[isSyncing, syncProgress]}
renderItem={({ item }) => {
if (item.type === 'section') {
return <Text style={styles.headerText}>{item.label}</Text>;
}
if (item.type === 'pendingCard') {
return (
<PendingSyncCard
syncing={syncing}
pendingCount={pendingCount}
shortList={shortList}
onSyncPress={handleSyncButtonPress}
onCancel={cancelSync}
/>
);
}
if (item.type === 'upload') {
const task = item.data;
return (
<TouchableOpacity
style={[styles.fileRow, task.status === 'error' && styles.fileRowError]}
onPress={() => handleTaskPress(task)}
activeOpacity={task.status === 'error' ? 0.6 : 1}
>
{uploadStatusIcon(task)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{task.file.name}</Text>
{task.status === 'uploading' && (
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${task.progress}%` }]} />
</View>
)}
{task.status === 'error' && task.error && (
<Text style={styles.errorText} numberOfLines={1}>{task.error}</Text>
)}
{task.status === 'done' && (
<Text style={styles.doneText}>Upload terminé</Text>
)}
{task.status === 'pending' && (
<Text style={styles.pendingText}>En attente</Text>
)}
</View>
</TouchableOpacity>
);
}
if (item.type === 'error') {
const file = item.data;
return (
<TouchableOpacity
style={[styles.fileRow, styles.fileRowError]}
onPress={() => handleErrorFilePress(file)}
activeOpacity={0.6}
>
<MaterialIcons name="error" size={20} color="#E53935" />
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
<Text style={styles.errorText}>
Échec de synchronisation · toucher pour réessayer
</Text>
</View>
<MaterialIcons name="refresh" size={18} color="#E53935" />
</TouchableOpacity>
);
}
return null;
}}
contentContainerStyle={styles.list}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
list: {
padding: 16,
},
headerText: {
fontSize: 14,
color: '#666',
marginBottom: 12,
},
fileRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 10,
padding: 12,
marginBottom: 8,
gap: 12,
},
pendingCard: {
backgroundColor: '#fff',
borderRadius: 14,
padding: 16,
marginBottom: 8,
gap: 14,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
pendingCardTop: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
pendingCardIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#E3F2FD',
justifyContent: 'center',
alignItems: 'center',
},
pendingCardInfo: {
flex: 1,
},
pendingCardTitle: {
fontSize: 15,
color: '#333',
fontWeight: '600',
},
pendingCardSubtitle: {
fontSize: 13,
color: '#1976D2',
marginTop: 4,
},
pendingList: {
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
paddingTop: 12,
gap: 8,
},
pendingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
pendingRowText: {
flex: 1,
fontSize: 13,
color: '#999',
},
pendingRowActive: {
color: '#1976D2',
fontWeight: '500',
},
stopBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#E53935',
borderRadius: 8,
paddingVertical: 10,
marginTop: 4,
},
stopBtnText: {
fontSize: 14,
color: '#fff',
fontWeight: '600',
},
fileInfo: {
flex: 1,
},
fileName: {
fontSize: 15,
color: '#333',
fontWeight: '500',
},
fileMeta: {
fontSize: 12,
color: '#999',
marginTop: 2,
},
localBadge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#f5f5f5',
justifyContent: 'center',
alignItems: 'center',
},
fileRowError: {
backgroundColor: '#FFF0F0',
},
progressBar: {
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
marginTop: 6,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#1976D2',
borderRadius: 2,
},
errorText: {
fontSize: 12,
color: '#E53935',
marginTop: 2,
},
doneText: {
fontSize: 12,
color: '#4CAF50',
marginTop: 2,
},
pendingText: {
fontSize: 12,
color: '#FFA000',
marginTop: 2,
},
empty: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
},
emptySubtitle: {
fontSize: 14,
color: '#999',
},
});
+1 -2
View File
@@ -2,6 +2,5 @@ module.exports = function (api) {
api.cache(true); api.cache(true);
return { return {
presets: ['babel-preset-expo'], presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
}; };
}; };
-148
View File
@@ -1,148 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal } from 'react-native';
export interface ConfirmOption {
label: string;
onPress?: () => void;
destructive?: boolean;
}
interface ConfirmModalProps {
visible: boolean;
title: string;
message?: string;
options: ConfirmOption[];
onClose: () => void;
}
export function ConfirmModal({ visible, title, message, options, onClose }: ConfirmModalProps) {
const stacked = options.length > 2;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={onClose}>
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
<View style={styles.handle} />
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
<View style={[styles.optionsContainer, stacked && styles.optionsStacked]}>
{options.map((opt, i) => (
<TouchableOpacity
key={i}
style={[
stacked ? styles.stackedBtn : styles.sideBtn,
opt.destructive && (stacked ? styles.stackedDestructive : styles.sideDestructive),
!opt.destructive && !stacked && styles.sideBtnSecondary,
!opt.destructive && stacked && styles.stackedSecondary,
i < options.length - 1 && stacked && styles.stackedBtnBorder,
]}
onPress={() => {
onClose();
opt.onPress?.();
}}
>
<Text
style={[
stacked ? styles.stackedBtnText : styles.sideBtnText,
opt.destructive && (stacked ? styles.stackedDestructiveText : styles.sideDestructiveText),
!opt.destructive && !stacked && styles.sideBtnSecondaryText,
!opt.destructive && stacked && styles.stackedSecondaryText,
]}
>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
},
container: {
backgroundColor: '#fff',
borderRadius: 16,
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 20,
width: '85%',
},
handle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#ddd',
alignSelf: 'center',
marginBottom: 16,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#333',
marginBottom: 8,
},
message: {
fontSize: 14,
color: '#666',
lineHeight: 20,
marginBottom: 20,
},
optionsContainer: {
flexDirection: 'row',
gap: 10,
},
optionsStacked: {
flexDirection: 'column',
gap: 0,
},
sideBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 10,
alignItems: 'center',
},
sideDestructive: {
backgroundColor: '#E53935',
},
sideBtnSecondary: {
backgroundColor: '#f5f5f5',
},
sideBtnText: {
fontSize: 15,
fontWeight: '600',
},
sideDestructiveText: {
color: '#fff',
},
sideBtnSecondaryText: {
color: '#666',
},
stackedBtn: {
paddingVertical: 14,
alignItems: 'center',
},
stackedBtnBorder: {
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
stackedDestructive: {},
stackedSecondary: {},
stackedBtnText: {
fontSize: 16,
fontWeight: '600',
},
stackedDestructiveText: {
color: '#E53935',
},
stackedSecondaryText: {
color: '#333',
},
});
-251
View File
@@ -1,251 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { FileItem } from '../types';
import { TagChip } from './TagChip';
import { SyncStatusBadge } from './SyncStatusBadge';
import type { ComponentProps } from 'react';
type IconName = ComponentProps<typeof MaterialIcons>['name'];
interface FileCardProps {
file: FileItem;
onPress?: (file: FileItem) => void;
onLongPress?: (file: FileItem) => void;
selected?: boolean;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileInfo(mimeType: string, fileName: string): { icon: IconName; color: string; bg: string } {
if (mimeType.startsWith('image/')) return { icon: 'image', color: '#4CAF50', bg: '#E8F5E9' };
if (mimeType === 'application/pdf') return { icon: 'picture-as-pdf', color: '#E53935', bg: '#FFEBEE' };
if (mimeType.includes('word') || mimeType.includes('document')) return { icon: 'description', color: '#1565C0', bg: '#E3F2FD' };
if (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv')) return { icon: 'table-chart', color: '#2E7D32', bg: '#E8F5E9' };
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return { icon: 'slideshow', color: '#E65100', bg: '#FFF3E0' };
if (mimeType.startsWith('text/')) return { icon: 'article', color: '#546E7A', bg: '#ECEFF1' };
if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' };
if (mimeType.startsWith('audio/')) return { icon: 'audiotrack', color: '#AD1457', bg: '#FCE4EC' };
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('rar') || mimeType.includes('tar')) return { icon: 'folder-zip', color: '#6D4C41', bg: '#EFEBE9' };
if (mimeType.includes('json') || mimeType.includes('xml')) return { icon: 'code', color: '#37474F', bg: '#ECEFF1' };
return { icon: 'insert-drive-file', color: '#757575', bg: '#F5F5F5' };
}
function getExtension(fileName: string): string {
const ext = fileName.split('.').pop();
return ext ? ext.toUpperCase() : '';
}
export const FileCard = React.memo(function FileCard({ file, onPress, onLongPress, selected }: FileCardProps) {
const info = getFileInfo(file.mimeType, file.name);
const ext = getExtension(file.name);
const imageUri = file.thumbnailUrl || file.thumbnailLocal || (file.url && file.mimeType?.startsWith('image/') ? file.url : undefined) || (file.localUri && file.mimeType?.startsWith('image/') ? file.localUri : undefined);
const isFolder = file.isFolder;
const isUploading = file.isUploading;
return (
<TouchableOpacity
style={[styles.container, selected && styles.containerSelected]}
onPress={() => onPress?.(file)}
onLongPress={() => onLongPress?.(file)}
delayLongPress={400}
activeOpacity={0.7}
>
<View style={styles.thumbnailWrap}>
{isFolder ? (
<View style={[styles.thumbnail, styles.placeholder, { backgroundColor: '#FFF3E0' }]}>
<MaterialIcons name="folder" size={30} color="#F57C00" />
</View>
) : imageUri ? (
<Image
source={{ uri: imageUri }}
style={styles.thumbnail}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
/>
) : (
<View style={[styles.thumbnail, styles.placeholder, { backgroundColor: info.bg }]}>
{isUploading ? (
<ActivityIndicator size="small" color="#1976D2" />
) : (
<>
<MaterialIcons name={info.icon} size={26} color={info.color} />
{ext.length <= 4 && <Text style={[styles.ext, { color: info.color }]}>{ext}</Text>}
</>
)}
</View>
)}
{selected && (
<View style={styles.selectedOverlay}>
<View style={styles.checkCircle}>
<MaterialIcons name="check" size={16} color="#fff" />
</View>
</View>
)}
{file.syncStatus && !isUploading && (
<View style={styles.badge}>
<SyncStatusBadge status={file.syncStatus} size={16} />
</View>
)}
</View>
<View style={styles.body}>
<View style={styles.header}>
<Text style={styles.name} numberOfLines={1}>
{file.name}
</Text>
{!isFolder && file.size > 0 && (
<Text style={styles.size}>{formatSize(file.size)}</Text>
)}
</View>
{file.syncStatus && !isUploading && (
<View style={styles.statusRow}>
<SyncStatusBadge status={file.syncStatus} showLabel />
</View>
)}
{isUploading ? (
<View style={styles.uploadRow}>
<MaterialIcons name="cloud-upload" size={14} color="#1976D2" />
<Text style={styles.uploadText}>
Upload {(file.uploadProgress ?? 0)}%
</Text>
</View>
) : file.ocrText ? (
<Text style={styles.preview} numberOfLines={2}>
{file.ocrText}
</Text>
) : null}
{file.tags && file.tags.length > 0 && (
<View style={styles.tags}>
{file.tags.slice(0, 3).map((tag) => (
<TagChip key={tag.id} name={tag.tag_name} />
))}
</View>
)}
</View>
</TouchableOpacity>
);
});
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: '#fff',
borderRadius: 10,
padding: 10,
marginBottom: 8,
gap: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
},
containerSelected: {
opacity: 0.85,
borderWidth: 1.5,
borderColor: '#1976D2',
},
thumbnailWrap: {
width: 64,
height: 64,
borderRadius: 8,
overflow: 'hidden',
},
thumbnail: {
width: 64,
height: 64,
borderRadius: 8,
},
placeholder: {
justifyContent: 'center',
alignItems: 'center',
gap: 1,
},
ext: {
fontSize: 9,
fontWeight: '700',
},
selectedOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'flex-start',
alignItems: 'flex-end',
padding: 4,
},
checkCircle: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
},
badge: {
position: 'absolute',
bottom: 4,
left: 4,
},
body: {
flex: 1,
justifyContent: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
gap: 8,
},
name: {
fontSize: 15,
fontWeight: '600',
color: '#333',
flex: 1,
},
size: {
fontSize: 12,
color: '#999',
},
statusRow: {
flexDirection: 'row',
marginBottom: 6,
},
preview: {
fontSize: 13,
color: '#666',
marginBottom: 6,
lineHeight: 18,
},
uploadRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 6,
},
uploadText: {
fontSize: 13,
color: '#1976D2',
fontWeight: '500',
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
},
});
-159
View File
@@ -1,159 +0,0 @@
import React from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
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'];
interface FileTypeInfo {
icon: IconName;
color: string;
bg: string;
}
function getFileInfo(mimeType: string, fileName: string): FileTypeInfo {
if (mimeType.startsWith('image/')) return { icon: 'image', color: '#4CAF50', bg: '#E8F5E9' };
if (mimeType === 'application/pdf') return { icon: 'picture-as-pdf', color: '#E53935', bg: '#FFEBEE' };
if (mimeType.includes('word') || mimeType.includes('document')) return { icon: 'description', color: '#1565C0', bg: '#E3F2FD' };
if (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv')) return { icon: 'table-chart', color: '#2E7D32', bg: '#E8F5E9' };
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return { icon: 'slideshow', color: '#E65100', bg: '#FFF3E0' };
if (mimeType.startsWith('text/')) return { icon: 'article', color: '#546E7A', bg: '#ECEFF1' };
if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' };
if (mimeType.startsWith('audio/')) return { icon: 'audiotrack', color: '#AD1457', bg: '#FCE4EC' };
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('rar') || mimeType.includes('tar')) return { icon: 'folder-zip', color: '#6D4C41', bg: '#EFEBE9' };
if (mimeType.includes('json') || mimeType.includes('xml')) return { icon: 'code', color: '#37474F', bg: '#ECEFF1' };
return { icon: 'insert-drive-file', color: '#757575', bg: '#F5F5F5' };
}
function getExtension(fileName: string): string {
const ext = fileName.split('.').pop();
return ext ? ext.toUpperCase() : '';
}
interface FileThumbnailProps {
uri?: string;
thumbnailUrl?: string;
thumbnailLocal?: string;
mimeType: string;
fileName: string;
size: number;
isLoading?: boolean;
syncStatus?: SyncStatus;
isFolder?: boolean;
isUploading?: boolean;
uploadProgress?: number;
}
export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, thumbnailLocal, mimeType, fileName, size, isLoading, syncStatus, isFolder, isUploading, uploadProgress }: FileThumbnailProps) {
const info = getFileInfo(mimeType, fileName);
const ext = getExtension(fileName);
if (isFolder) {
return (
<View style={[styles.container, { width: size, height: size, backgroundColor: '#FFF3E0' }]}>
<MaterialIcons name="folder" size={size * 0.45} color="#F57C00" />
</View>
);
}
if (isLoading) {
return (
<View style={[styles.container, { width: size, height: size, backgroundColor: info.bg }]}>
<ActivityIndicator size="small" color={info.color} />
</View>
);
}
const imageUri = thumbnailUrl || thumbnailLocal || (uri && mimeType.startsWith('image/') ? uri : undefined);
if (imageUri) {
return (
<View style={{ width: size, height: size }}>
<Image
source={imageUri}
style={[styles.image, { width: size, height: size }]}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
{syncStatus && <SyncStatusBadge status={syncStatus} />}
{isUploading && (
<View style={styles.uploadOverlay}>
<ActivityIndicator size="small" color="#fff" />
<Text style={styles.uploadProgressText}>{uploadProgress ?? 0}%</Text>
<View style={[styles.uploadProgressBar, { width: `${uploadProgress ?? 0}%` }]} />
</View>
)}
</View>
);
}
return (
<View style={[styles.container, { width: size, height: size, backgroundColor: isUploading ? '#E3F2FD' : info.bg }]}>
{isUploading ? (
<View style={styles.uploadGhost}>
<MaterialIcons name="cloud-upload" size={size * 0.3} color="#1976D2" />
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.uploadPercent}>{uploadProgress ?? 0}%</Text>
</View>
) : (
<>
<MaterialIcons name={info.icon} size={size * 0.35} color={info.color} />
{ext.length <= 4 && (
<Text style={[styles.ext, { color: info.color }]}>{ext}</Text>
)}
</>
)}
{syncStatus && <SyncStatusBadge status={syncStatus} />}
</View>
);
});
const styles = StyleSheet.create({
container: {
borderRadius: 6,
justifyContent: 'center',
alignItems: 'center',
gap: 2,
},
image: {
borderRadius: 6,
},
ext: {
fontSize: 11,
fontWeight: '700',
},
uploadGhost: {
alignItems: 'center',
gap: 4,
},
uploadPercent: {
fontSize: 11,
fontWeight: '700',
color: '#1976D2',
},
uploadOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0,0,0,0.45)',
borderRadius: 6,
justifyContent: 'center',
alignItems: 'center',
gap: 4,
},
uploadProgressText: {
fontSize: 12,
fontWeight: '700',
color: '#fff',
},
uploadProgressBar: {
position: 'absolute',
bottom: 0,
left: 0,
height: 3,
backgroundColor: '#1976D2',
borderBottomLeftRadius: 6,
},
});
-54
View File
@@ -1,54 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export function NetworkStatusBar() {
const { isOnline } = useNetworkStatus();
if (isOnline) {
return (
<View style={styles.container}>
<View style={styles.dotOnline} />
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.offlineBadge}>
<MaterialIcons name="cloud-off" size={14} color="#fff" />
<Text style={styles.offlineText}>Offline</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginRight: 4,
padding: 4,
alignItems: 'center',
justifyContent: 'center',
},
dotOnline: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#4CAF50',
},
offlineBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#E53935',
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 4,
gap: 4,
},
offlineText: {
fontSize: 11,
fontWeight: '700',
color: '#fff',
},
});
-76
View File
@@ -1,76 +0,0 @@
import React from 'react';
import { View, Image, FlatList, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { CapturedPhoto } from '../types';
interface PhotoThumbnailStripProps {
photos: CapturedPhoto[];
onRemove: (id: string) => void;
}
export function PhotoThumbnailStrip({ photos, onRemove }: PhotoThumbnailStripProps) {
if (photos.length === 0) return null;
return (
<View style={styles.container}>
<FlatList
data={photos}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.list}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.thumb}>
<Image source={{ uri: item.uri }} style={styles.image} />
<TouchableOpacity
style={styles.removeButton}
onPress={() => onRemove(item.id)}
>
<Text style={styles.removeText}></Text>
</TouchableOpacity>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 120,
left: 0,
right: 0,
height: 80,
},
list: {
paddingHorizontal: 16,
gap: 8,
},
thumb: {
width: 64,
height: 64,
borderRadius: 8,
overflow: 'hidden',
backgroundColor: '#333',
},
image: {
width: '100%',
height: '100%',
},
removeButton: {
position: 'absolute',
top: 2,
right: 2,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
},
removeText: {
color: '#fff',
fontSize: 11,
fontWeight: '700',
},
});
-117
View File
@@ -1,117 +0,0 @@
import React from 'react';
import { View, TextInput, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import type { ComponentProps } from 'react';
type IconName = ComponentProps<typeof MaterialIcons>['name'];
export interface SearchFilters {
name: boolean;
ocrText: boolean;
}
export type SortKey = 'date' | 'name' | 'size';
export type SortDirection = 'asc' | 'desc';
export interface SortState {
key: SortKey;
direction: SortDirection;
}
interface SearchBarProps {
query: string;
onQueryChange: (q: string) => void;
onClear: () => void;
filters: SearchFilters;
onFiltersChange: (f: SearchFilters) => void;
onSettingsPress: () => void;
sort: SortState;
bottomPadding?: number;
}
export function SearchBar({ query, onQueryChange, onClear, filters, onSettingsPress, sort, bottomPadding = 0 }: SearchBarProps) {
const hasActiveFilter = filters.name || filters.ocrText || sort.key !== 'date';
return (
<View style={[styles.wrapper, { paddingBottom: bottomPadding }]}>
<View style={styles.inputRow}>
<View style={styles.inputContainer}>
<MaterialIcons name="search" size={20} color="#999" style={styles.searchIcon} />
<TextInput
style={styles.input}
placeholder="Rechercher..."
placeholderTextColor="#999"
value={query}
onChangeText={onQueryChange}
returnKeyType="search"
autoCorrect={false}
/>
{query.length > 0 && (
<TouchableOpacity onPress={onClear} style={styles.clearBtn}>
<MaterialIcons name="close" size={18} color="#999" />
</TouchableOpacity>
)}
</View>
<TouchableOpacity
style={[styles.iconBtn, hasActiveFilter && styles.iconBtnActive]}
onPress={onSettingsPress}
>
<MaterialIcons
name="tune"
size={22}
color={hasActiveFilter ? '#fff' : '#1976D2'}
/>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
gap: 8,
},
inputContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f5f5f5',
borderRadius: 10,
paddingHorizontal: 10,
},
searchIcon: {
marginRight: 6,
},
input: {
flex: 1,
paddingVertical: 8,
fontSize: 14,
color: '#333',
},
clearBtn: {
padding: 4,
},
iconBtn: {
width: 40,
height: 40,
borderRadius: 10,
borderWidth: 1,
borderColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#fff',
},
iconBtnActive: {
backgroundColor: '#1976D2',
},
});
-205
View File
@@ -1,205 +0,0 @@
import React, { useCallback } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS } from 'react-native-reanimated';
const PANEL_HEIGHT = 300;
const PANEL_HEADER_VISIBLE = 80;
interface SelectionPanelProps {
selectedCount: number;
onClose: () => void;
onDelete?: () => void;
onEdit?: () => void;
onTags?: () => void;
onFolder?: () => void;
onMove?: () => void;
insetsBottom: number;
}
export function SelectionPanel({
selectedCount,
onClose,
onDelete,
onEdit,
onTags,
onFolder,
onMove,
insetsBottom,
}: SelectionPanelProps) {
const panelOffset = useSharedValue(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
const panelStartY = useSharedValue(0);
const isPanelExpanded = useSharedValue(false);
const [panelExpanded, setPanelExpanded] = React.useState(false);
const togglePanelJS = useCallback(() => {
if (isPanelExpanded.value) {
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
isPanelExpanded.value = false;
setPanelExpanded(false);
} else {
panelOffset.value = withSpring(0);
isPanelExpanded.value = true;
setPanelExpanded(true);
}
}, []);
const panGesture = Gesture.Pan()
.onStart(() => {
panelStartY.value = panelOffset.value;
})
.onUpdate((event) => {
const offset = Math.max(0, Math.min(PANEL_HEIGHT - PANEL_HEADER_VISIBLE, panelStartY.value + event.translationY));
panelOffset.value = offset;
})
.onEnd(() => {
if (panelOffset.value > (PANEL_HEIGHT - PANEL_HEADER_VISIBLE) / 2) {
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
isPanelExpanded.value = false;
runOnJS(setPanelExpanded)(false);
} else {
panelOffset.value = withSpring(0);
isPanelExpanded.value = true;
runOnJS(setPanelExpanded)(true);
}
});
const panelAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: panelOffset.value }],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.panel, { paddingBottom: insetsBottom + 12 }, panelAnimatedStyle]}>
<TouchableOpacity onPress={togglePanelJS} activeOpacity={0.7}>
<View style={styles.panelHandle} />
<View style={styles.panelHeader}>
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<MaterialIcons name="close" size={22} color="#333" />
</TouchableOpacity>
<Text style={styles.selectionCount}>
{selectedCount} sélectionnée{selectedCount > 1 ? 's' : ''}
</Text>
<TouchableOpacity onPress={onClose} style={styles.selectAllBtn}>
<Text style={styles.selectAllText}>Tout</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
<View style={styles.panelBody}>
<View style={styles.actionsGrid}>
{onDelete && (
<TouchableOpacity style={[styles.actionBtn, styles.deleteBtn]} onPress={onDelete}>
<MaterialIcons name="delete" size={22} color="#E53935" />
<Text style={[styles.actionLabel, { color: '#E53935' }]}>Supprimer</Text>
</TouchableOpacity>
)}
{onEdit && (
<TouchableOpacity style={[styles.actionBtn, styles.editBtn]} onPress={onEdit}>
<MaterialIcons name="edit" size={22} color="#1E88E5" />
<Text style={[styles.actionLabel, { color: '#1E88E5' }]}>Éditer</Text>
</TouchableOpacity>
)}
{onTags && (
<TouchableOpacity style={[styles.actionBtn, styles.tagBtn]} onPress={onTags}>
<MaterialIcons name="label" size={22} color="#8E24AA" />
<Text style={[styles.actionLabel, { color: '#8E24AA' }]}>Tags</Text>
</TouchableOpacity>
)}
{onFolder && (
<TouchableOpacity style={[styles.actionBtn, styles.folderBtn]} onPress={onFolder}>
<MaterialIcons name="create-new-folder" size={22} color="#F57C00" />
<Text style={[styles.actionLabel, { color: '#F57C00' }]}>Dossier</Text>
</TouchableOpacity>
)}
{onMove && (
<TouchableOpacity style={[styles.actionBtn, styles.moveBtn]} onPress={onMove}>
<MaterialIcons name="drive-file-move" size={22} color="#00897B" />
<Text style={[styles.actionLabel, { color: '#00897B' }]}>Déplacer</Text>
</TouchableOpacity>
)}
</View>
</View>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
panel: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 20,
paddingTop: 12,
height: PANEL_HEIGHT,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 10,
},
panelHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#ddd',
alignSelf: 'center',
marginBottom: 12,
},
panelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
closeBtn: {
padding: 4,
},
selectionCount: {
fontSize: 16,
fontWeight: '700',
color: '#333',
},
selectAllBtn: {
paddingHorizontal: 8,
paddingVertical: 4,
},
selectAllText: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
panelBody: {
flex: 1,
},
actionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
actionBtn: {
width: '47%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
paddingVertical: 14,
borderRadius: 12,
backgroundColor: '#f5f5f5',
},
actionLabel: {
fontSize: 15,
fontWeight: '600',
},
deleteBtn: {},
editBtn: {},
tagBtn: {},
folderBtn: {},
moveBtn: {},
});
-670
View File
@@ -1,670 +0,0 @@
import React, { useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Modal,
ScrollView,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { StoredFolder, SyncMode, SyncGlobalMode, type FolderSource } from '../services/safDirectory';
import { ConfirmModal } from './ConfirmModal';
type SettingsView = 'menu' | 'folders' | 'sync';
interface SettingsModalProps {
visible: boolean;
onClose: () => void;
folders: StoredFolder[];
onToggleVisibility: (folderId: string) => void;
onRemoveFolder: (folderId: string) => void;
onAddFolderRecursive: () => void;
onUpdateSyncMode: (folderId: string, mode: SyncMode) => void;
onUpdateSyncCellular: (folderId: string, enabled: boolean) => void;
globalSyncMode: SyncGlobalMode;
onSetGlobalSyncMode: (mode: SyncGlobalMode) => void;
globalSyncCellular: boolean;
onSetGlobalSyncCellular: (enabled: boolean) => void;
}
const SYNC_MODES: { value: SyncMode; label: string; icon: string; color: string }[] = [
{ value: 'none', label: 'Aucun', icon: 'sync-disabled', color: '#999' },
{ value: 'manual', label: 'Manuel', icon: 'sync', color: '#1976D2' },
{ value: 'auto', label: 'Auto', icon: 'sync-problem', color: '#43A047' },
];
const GLOBAL_MODES: { value: SyncGlobalMode; label: string; description: string; icon: string; color: string }[] = [
{ value: 'off', label: 'Désactivé', description: 'Aucun upload automatique', icon: 'sync-disabled', color: '#999' },
{ value: 'auto', label: 'Automatique', description: 'Tout synchroniser automatiquement', icon: 'sync-problem', color: '#43A047' },
{ value: 'manual', label: 'Par dossier', description: 'Choisir dossier par dossier', icon: 'tune', color: '#1976D2' },
];
export function SettingsModal({
visible,
onClose,
folders,
onToggleVisibility,
onRemoveFolder,
onAddFolderRecursive,
onUpdateSyncMode,
onUpdateSyncCellular,
globalSyncMode,
onSetGlobalSyncMode,
globalSyncCellular,
onSetGlobalSyncCellular,
}: SettingsModalProps) {
const [view, setView] = useState<SettingsView>('menu');
const [confirmRemoveFolder, setConfirmRemoveFolder] = useState<StoredFolder | null>(null);
const handleClose = useCallback(() => {
setView('menu');
onClose();
}, [onClose]);
const handleRemoveFolder = useCallback(
(folder: StoredFolder) => {
setConfirmRemoveFolder(folder);
},
[]
);
const handleRemoveFolderConfirm = useCallback(() => {
if (confirmRemoveFolder) {
onRemoveFolder(confirmRemoveFolder.id);
}
setConfirmRemoveFolder(null);
}, [confirmRemoveFolder, onRemoveFolder]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={handleClose}
>
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
<View style={styles.handle} />
{view === 'menu' && (
<MenuView
onSelect={(v) => setView(v)}
onClose={handleClose}
/>
)}
{view === 'folders' && (
<FoldersView
folders={folders}
onBack={() => setView('menu')}
onToggleVisibility={onToggleVisibility}
onRemoveFolder={handleRemoveFolder}
onAddFolderRecursive={onAddFolderRecursive}
/>
)}
{view === 'sync' && (
<SyncView
folders={folders}
onBack={() => setView('menu')}
onUpdateSyncMode={onUpdateSyncMode}
onUpdateSyncCellular={onUpdateSyncCellular}
globalSyncMode={globalSyncMode}
onSetGlobalSyncMode={onSetGlobalSyncMode}
globalSyncCellular={globalSyncCellular}
onSetGlobalSyncCellular={onSetGlobalSyncCellular}
/>
)}
</TouchableOpacity>
</TouchableOpacity>
<ConfirmModal
visible={confirmRemoveFolder !== null}
title="Supprimer le dossier"
message="Le dossier sera retiré de la liste. Les fichiers resteront sur votre appareil."
options={[
{ label: 'Annuler' },
{ label: 'Supprimer', destructive: true, onPress: handleRemoveFolderConfirm },
]}
onClose={() => setConfirmRemoveFolder(null)}
/>
</Modal>
);
}
function MenuView({ onSelect, onClose }: { onSelect: (v: SettingsView) => void; onClose: () => void }) {
return (
<View>
<Text style={styles.title}>Paramètres</Text>
<TouchableOpacity style={styles.menuItem} onPress={() => onSelect('folders')}>
<View style={[styles.menuIcon, { backgroundColor: '#FFF3E0' }]}>
<MaterialIcons name="folder" size={22} color="#F57C00" />
</View>
<View style={styles.menuTextContainer}>
<Text style={styles.menuLabel}>Dossiers</Text>
<Text style={styles.menuDescription}>Gérer les dossiers affichés</Text>
</View>
<MaterialIcons name="chevron-right" size={24} color="#ccc" />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem} onPress={() => onSelect('sync')}>
<View style={[styles.menuIcon, { backgroundColor: '#E3F2FD' }]}>
<MaterialIcons name="cloud-sync" size={22} color="#1976D2" />
</View>
<View style={styles.menuTextContainer}>
<Text style={styles.menuLabel}>Synchronisation</Text>
<Text style={styles.menuDescription}>Configurer l'upload automatique</Text>
</View>
<MaterialIcons name="chevron-right" size={24} color="#ccc" />
</TouchableOpacity>
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<Text style={styles.closeBtnText}>Fermer</Text>
</TouchableOpacity>
</View>
);
}
function folderIcon(folder: StoredFolder): { name: keyof typeof MaterialIcons.glyphMap; color: string } {
switch (folder.source) {
case 'media-library':
return { name: 'photo-library', color: '#43A047' };
case 'recursive':
return { name: 'subdirectory-arrow-right', color: '#8E24AA' };
default:
return { name: 'folder', color: '#F57C00' };
}
}
function FoldersView({
folders,
onBack,
onToggleVisibility,
onRemoveFolder,
onAddFolderRecursive,
}: {
folders: StoredFolder[];
onBack: () => void;
onToggleVisibility: (id: string) => void;
onRemoveFolder: (folder: StoredFolder) => void;
onAddFolderRecursive: () => void;
}) {
return (
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
<View style={styles.viewHeader}>
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
<MaterialIcons name="arrow-back" size={22} color="#333" />
</TouchableOpacity>
<Text style={styles.title}>Dossiers</Text>
</View>
{folders.length === 0 ? (
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
) : (
folders.map((folder) => {
const icon = folderIcon(folder);
return (
<View key={folder.id} style={styles.folderRow}>
<MaterialIcons name={icon.name} size={20} color={icon.color} />
<Text style={styles.folderName} numberOfLines={1}>
{folder.name}
</Text>
<TouchableOpacity
onPress={() => onToggleVisibility(folder.id)}
style={styles.actionBtn}
>
<MaterialIcons
name={folder.visible ? 'visibility' : 'visibility-off'}
size={20}
color={folder.visible ? '#1976D2' : '#999'}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onRemoveFolder(folder)}
style={styles.actionBtn}
>
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
</TouchableOpacity>
</View>
);
})
)}
<TouchableOpacity style={styles.addBtn} onPress={onAddFolderRecursive}>
<MaterialIcons name="add" size={20} color="#fff" />
<Text style={styles.addBtnText}>Ajouter un dossier</Text>
</TouchableOpacity>
</ScrollView>
);
}
function SyncView({
folders,
onBack,
onUpdateSyncMode,
onUpdateSyncCellular,
globalSyncMode,
onSetGlobalSyncMode,
globalSyncCellular,
onSetGlobalSyncCellular,
}: {
folders: StoredFolder[];
onBack: () => void;
onUpdateSyncMode: (id: string, mode: SyncMode) => void;
onUpdateSyncCellular: (id: string, enabled: boolean) => void;
globalSyncMode: SyncGlobalMode;
onSetGlobalSyncMode: (mode: SyncGlobalMode) => void;
globalSyncCellular: boolean;
onSetGlobalSyncCellular: (enabled: boolean) => void;
}) {
return (
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
<View style={styles.viewHeader}>
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
<MaterialIcons name="arrow-back" size={22} color="#333" />
</TouchableOpacity>
<Text style={styles.title}>Synchronisation</Text>
</View>
<Text style={styles.syncInfo}>
Choisissez comment vos fichiers sont envoyés au serveur.
</Text>
<View style={styles.globalModeCard}>
<Text style={styles.sectionLabel}>Mode de synchronisation</Text>
{GLOBAL_MODES.map((mode) => (
<TouchableOpacity
key={mode.value}
style={[
styles.globalModeRow,
globalSyncMode === mode.value && styles.globalModeRowActive,
]}
onPress={() => onSetGlobalSyncMode(mode.value)}
>
<MaterialIcons
name={globalSyncMode === mode.value ? 'radio-button-checked' : 'radio-button-unchecked'}
size={20}
color={globalSyncMode === mode.value ? mode.color : '#999'}
/>
<View style={styles.globalModeTextContainer}>
<Text
style={[
styles.globalModeLabel,
globalSyncMode === mode.value && { color: mode.color },
]}
>
{mode.label}
</Text>
<Text style={styles.globalModeDescription}>{mode.description}</Text>
</View>
<MaterialIcons
name={mode.icon as any}
size={20}
color={globalSyncMode === mode.value ? mode.color : '#ccc'}
/>
</TouchableOpacity>
))}
</View>
{globalSyncMode === 'auto' && (
<View style={styles.cellularCard}>
<View style={styles.cellularRow}>
<MaterialIcons name="cell-tower" size={20} color="#666" />
<Text style={styles.cellularLabel}>Autoriser le réseau cellulaire</Text>
<TouchableOpacity
style={[
styles.toggleBtn,
globalSyncCellular && styles.toggleBtnActive,
]}
onPress={() => onSetGlobalSyncCellular(!globalSyncCellular)}
>
<View
style={[
styles.toggleDot,
globalSyncCellular && styles.toggleDotActive,
]}
/>
</TouchableOpacity>
</View>
<Text style={styles.cellularHint}>
{globalSyncCellular
? 'Upload via WiFi et données mobiles'
: 'Upload uniquement en WiFi'}
</Text>
</View>
)}
{globalSyncMode === 'manual' && (
<View style={styles.perFolderSection}>
<Text style={styles.sectionLabel}>Configuration par dossier</Text>
{folders.length === 0 ? (
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
) : (
folders.map((folder) => (
<View key={folder.id} style={styles.syncFolderCard}>
<View style={styles.syncFolderHeader}>
<MaterialIcons name="folder" size={18} color="#F57C00" />
<Text style={styles.syncFolderName} numberOfLines={1}>
{folder.name}
</Text>
</View>
<View style={styles.radioGroup}>
{SYNC_MODES.map((mode) => (
<TouchableOpacity
key={mode.value}
style={[
styles.radioBtn,
folder.syncMode === mode.value && styles.radioBtnActive,
]}
onPress={() => onUpdateSyncMode(folder.id, mode.value)}
>
<MaterialIcons
name={
folder.syncMode === mode.value
? 'radio-button-checked'
: 'radio-button-unchecked'
}
size={18}
color={folder.syncMode === mode.value ? mode.color : '#999'}
/>
<Text
style={[
styles.radioLabel,
folder.syncMode === mode.value && { color: mode.color },
]}
>
{mode.label}
</Text>
</TouchableOpacity>
))}
</View>
{folder.syncMode !== 'none' && (
<View style={styles.cellularRow}>
<MaterialIcons name="cell-tower" size={18} color="#666" />
<Text style={styles.cellularLabel}>Réseau cellulaire</Text>
<TouchableOpacity
style={[
styles.toggleBtn,
folder.syncCellular && styles.toggleBtnActive,
]}
onPress={() => onUpdateSyncCellular(folder.id, !folder.syncCellular)}
>
<View
style={[
styles.toggleDot,
folder.syncCellular && styles.toggleDotActive,
]}
/>
</TouchableOpacity>
</View>
)}
</View>
))
)}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
},
container: {
backgroundColor: '#fff',
borderRadius: 16,
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 20,
width: '85%',
maxHeight: '80%',
},
handle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#ddd',
alignSelf: 'center',
marginBottom: 16,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#333',
marginBottom: 16,
},
menuIcon: {
width: 40,
height: 40,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
gap: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
menuTextContainer: {
flex: 1,
},
menuLabel: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
menuDescription: {
fontSize: 13,
color: '#999',
marginTop: 2,
},
closeBtn: {
marginTop: 16,
alignItems: 'center',
paddingVertical: 10,
},
closeBtnText: {
fontSize: 15,
color: '#666',
},
viewContainer: {
maxHeight: 500,
},
viewHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginBottom: 8,
},
backBtn: {
padding: 4,
},
emptyText: {
fontSize: 14,
color: '#999',
textAlign: 'center',
marginVertical: 20,
},
folderRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
folderName: {
flex: 1,
fontSize: 15,
color: '#333',
},
actionBtn: {
padding: 6,
},
addBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F57C00',
borderRadius: 10,
paddingVertical: 12,
marginTop: 16,
gap: 8,
},
addBtnText: {
fontSize: 15,
color: '#fff',
fontWeight: '600',
},
syncInfo: {
fontSize: 13,
color: '#666',
marginBottom: 16,
lineHeight: 18,
},
globalModeCard: {
backgroundColor: '#fafafa',
borderRadius: 10,
padding: 12,
marginBottom: 12,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
color: '#666',
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
globalModeRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 8,
gap: 10,
borderRadius: 8,
marginBottom: 4,
},
globalModeRowActive: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#e0e0e0',
},
globalModeTextContainer: {
flex: 1,
},
globalModeLabel: {
fontSize: 15,
fontWeight: '600',
color: '#333',
},
globalModeDescription: {
fontSize: 12,
color: '#999',
marginTop: 2,
},
cellularCard: {
backgroundColor: '#fafafa',
borderRadius: 10,
padding: 12,
marginBottom: 12,
},
cellularRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
cellularLabel: {
flex: 1,
fontSize: 14,
color: '#333',
},
cellularHint: {
fontSize: 12,
color: '#999',
marginTop: 6,
marginLeft: 28,
},
perFolderSection: {
marginTop: 4,
},
syncFolderCard: {
backgroundColor: '#fafafa',
borderRadius: 10,
padding: 12,
marginBottom: 10,
},
syncFolderHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 10,
},
syncFolderName: {
fontSize: 15,
fontWeight: '600',
color: '#333',
flex: 1,
},
radioGroup: {
flexDirection: 'row',
gap: 6,
marginBottom: 10,
},
radioBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
paddingHorizontal: 6,
borderRadius: 8,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#e0e0e0',
gap: 4,
},
radioBtnActive: {
borderWidth: 1.5,
},
radioLabel: {
fontSize: 12,
fontWeight: '600',
color: '#999',
},
toggleBtn: {
width: 44,
height: 24,
borderRadius: 12,
backgroundColor: '#ddd',
justifyContent: 'center',
paddingHorizontal: 2,
},
toggleBtnActive: {
backgroundColor: '#1976D2',
},
toggleDot: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#fff',
},
toggleDotActive: {
alignSelf: 'flex-end',
},
});
-82
View File
@@ -1,82 +0,0 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import type { ComponentProps } from 'react';
import type { SortState } from './SearchBar';
type IconName = ComponentProps<typeof MaterialIcons>['name'];
interface SortChipsProps {
sort: SortState;
onSortChange: (s: SortState) => void;
}
const OPTIONS: { key: SortState['key']; label: string; icon: IconName }[] = [
{ key: 'name', label: 'A-Z', icon: 'sort-by-alpha' },
{ key: 'date', label: 'Date', icon: 'schedule' },
];
export function SortChips({ sort, onSortChange }: SortChipsProps) {
const select = (key: SortState['key']) => {
if (sort.key === key) {
onSortChange({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' });
} else {
onSortChange({ key, direction: key === 'name' ? 'asc' : 'desc' });
}
};
return (
<View style={styles.container}>
{OPTIONS.map((opt) => {
const active = sort.key === opt.key;
return (
<TouchableOpacity
key={opt.key}
style={[styles.chip, active && styles.chipActive]}
onPress={() => select(opt.key)}
>
<MaterialIcons
name={active && sort.direction === 'desc' ? 'arrow-downward' : active && sort.direction === 'asc' ? 'arrow-upward' : opt.icon}
size={16}
color={active ? '#fff' : '#1976D2'}
/>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{opt.label}</Text>
</TouchableOpacity>
);
})}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 15,
paddingVertical: 8,
backgroundColor: '#f5f5f5',
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: '#1976D2',
backgroundColor: '#fff',
},
chipActive: {
backgroundColor: '#1976D2',
},
chipText: {
fontSize: 13,
fontWeight: '600',
color: '#1976D2',
},
chipTextActive: {
color: '#fff',
},
});
-63
View File
@@ -1,63 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { SyncStatus } from '../types';
interface SyncStatusBadgeProps {
status: SyncStatus;
size?: number;
showLabel?: boolean;
}
const STATUS_CONFIG: Record<SyncStatus, { icon: string; color: string; bg: string; label: string }> = {
local: { icon: 'phone-android', color: '#757575', bg: '#F5F5F5', label: 'Local' },
syncing: { icon: 'sync', color: '#FF9800', bg: '#FFF3E0', label: 'Sync...' },
synced: { icon: 'sync', color: '#4CAF50', bg: '#E8F5E9', label: 'Les deux' },
cloud: { icon: 'cloud', color: '#1976D2', bg: '#E3F2FD', label: 'Cloud' },
conflict: { icon: 'warning', color: '#E53935', bg: '#FFEBEE', label: 'Conflit' },
};
export function SyncStatusBadge({ status, size = 16, showLabel }: SyncStatusBadgeProps) {
const config = STATUS_CONFIG[status];
const iconSize = Math.round(size * 0.7);
if (showLabel) {
return (
<View style={[styles.pill, { backgroundColor: config.bg }]}>
<MaterialIcons name={config.icon as any} size={12} color={config.color} />
<Text style={[styles.pillText, { color: config.color }]}>{config.label}</Text>
</View>
);
}
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)',
},
pill: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
pillText: {
fontSize: 12,
fontWeight: '600',
},
});
-89
View File
@@ -1,89 +0,0 @@
import React, { useEffect } from 'react';
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
Easing,
cancelAnimation,
} from 'react-native-reanimated';
interface SyncStatusIconProps {
isSyncing: boolean;
pendingCount: number;
isUploading: boolean;
uploadPendingCount: number;
onPress: () => void;
}
export function SyncStatusIcon({ isSyncing, pendingCount, isUploading, uploadPendingCount, onPress }: SyncStatusIconProps) {
const rotation = useSharedValue(0);
const isActive = isSyncing || isUploading;
const totalPending = pendingCount + uploadPendingCount;
useEffect(() => {
if (isActive) {
rotation.value = withRepeat(
withSequence(
withTiming(360, { duration: 1000, easing: Easing.linear }),
withTiming(0, { duration: 0 })
),
-1
);
} else {
cancelAnimation(rotation);
rotation.value = withTiming(0, { duration: 200 });
}
}, [isActive, rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<TouchableOpacity onPress={onPress} style={styles.container}>
<Animated.View style={animatedStyle}>
<MaterialIcons
name="sync"
size={22}
color={isActive ? '#1976D2' : totalPending > 0 ? '#F57C00' : '#666'}
/>
</Animated.View>
{totalPending > 0 && !isActive && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{totalPending > 99 ? '99+' : totalPending}
</Text>
</View>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
marginRight: 4,
padding: 8,
},
badge: {
position: 'absolute',
top: 2,
right: 0,
backgroundColor: '#E53935',
borderRadius: 8,
minWidth: 16,
height: 16,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 4,
},
badgeText: {
fontSize: 9,
fontWeight: '700',
color: '#fff',
},
});
-50
View File
@@ -1,50 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
interface TagChipProps {
name: string;
onRemove?: () => void;
}
export function TagChip({ name, onRemove }: TagChipProps) {
return (
<View style={styles.container}>
<Text style={styles.text}>{name}</Text>
{onRemove && (
<TouchableOpacity onPress={onRemove} style={styles.removeButton}>
<Text style={styles.removeText}>×</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#E3F2FD',
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
gap: 4,
},
text: {
fontSize: 12,
color: '#1976D2',
fontWeight: '500',
},
removeButton: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: '#BBDEFB',
justifyContent: 'center',
alignItems: 'center',
},
removeText: {
fontSize: 12,
color: '#1976D2',
fontWeight: '700',
},
});
-124
View File
@@ -1,124 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import { UploadFile } from '../services/uploadQueue';
import { useUploadQueue } from '../hooks/useUploadQueue';
interface UploadModalProps {
visible: boolean;
onClose: () => void;
}
export function UploadModal({ visible, onClose }: UploadModalProps) {
const { enqueue } = useUploadQueue();
const pickDocuments = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: true,
});
if (result.canceled || result.assets.length === 0) return;
const files = result.assets.map((asset) => ({
uri: asset.uri,
type: asset.mimeType || 'application/octet-stream',
name: asset.name,
}));
enqueue(files);
onClose();
} catch {
Alert.alert('Erreur', "Impossible de sélectionner des documents");
}
};
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
<View style={styles.handle} />
<View style={styles.header}>
<Text style={styles.title}>Ajouter des fichiers</Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<MaterialIcons name="close" size={22} color="#999" />
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.docButton}
onPress={pickDocuments}
>
<MaterialIcons name="description" size={20} color="#fff" />
<Text style={styles.buttonText}>Sélectionner des documents</Text>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
},
container: {
backgroundColor: '#fff',
borderRadius: 16,
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 20,
width: '85%',
},
handle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#ddd',
alignSelf: 'center',
marginBottom: 16,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#333',
},
closeBtn: {
padding: 4,
},
docButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#4CAF50',
padding: 16,
borderRadius: 8,
gap: 8,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
-72
View File
@@ -1,72 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
interface UploadProgressProps {
progress?: number;
status: 'idle' | 'uploading' | 'processing' | 'success' | 'error';
error?: string;
uploadedCount?: number;
totalCount?: number;
}
export function UploadProgress({ progress, status, error, uploadedCount, totalCount }: UploadProgressProps) {
if (status === 'idle') return null;
const hasMulti = totalCount !== undefined && totalCount > 1;
return (
<View style={styles.container}>
{status === 'uploading' && (
<>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.text}>
{hasMulti
? `Upload ${uploadedCount || 0}/${totalCount}...`
: `Upload en cours...${progress !== undefined ? ` ${progress}%` : ''}`}
</Text>
</>
)}
{status === 'processing' && (
<>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.text}>Traitement OCR en cours...</Text>
</>
)}
{status === 'success' && (
<Text style={[styles.text, styles.success]}>
{hasMulti ? `${uploadedCount} fichiers uploadés !` : 'Upload terminé !'}
</Text>
)}
{status === 'error' && (
<Text style={[styles.text, styles.error]}>
{error || "Erreur lors de l'upload"}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: '#F5F5F5',
borderRadius: 8,
marginBottom: 16,
},
text: {
marginLeft: 8,
fontSize: 14,
color: '#333',
},
success: {
color: '#4CAF50',
},
error: {
color: '#F44336',
},
});
-185
View File
@@ -1,185 +0,0 @@
import React, { useCallback } from 'react';
import { StyleSheet, Pressable, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import {
Gesture,
GestureDetector,
} from 'react-native-gesture-handler';
interface ZoomableImageProps {
uri: string;
width: number;
height: number;
onClose?: () => void;
onSwipeVertical?: (direction: 'up' | 'down') => void;
}
const MAX_SCALE = 4;
const DOUBLE_TAP_SCALE = 2.5;
const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.5 };
export function ZoomableImage({ uri, width, height, onClose, onSwipeVertical }: ZoomableImageProps) {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedTranslateX = useSharedValue(0);
const savedTranslateY = useSharedValue(0);
const handleClose = useCallback(() => {
onClose?.();
}, [onClose]);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
onSwipeVertical?.(direction);
}, [onSwipeVertical]);
const pinch = Gesture.Pinch()
.onUpdate((e) => {
scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), MAX_SCALE);
})
.onEnd(() => {
if (scale.value < 1) {
scale.value = withSpring(1, SPRING_CONFIG);
translateX.value = withSpring(0, SPRING_CONFIG);
translateY.value = withSpring(0, SPRING_CONFIG);
savedScale.value = 1;
savedTranslateX.value = 0;
savedTranslateY.value = 0;
} else {
savedScale.value = scale.value;
}
});
const pan = Gesture.Pan()
.minDistance(5)
.onUpdate((e) => {
if (savedScale.value > 1) {
translateX.value = savedTranslateX.value + e.translationX;
translateY.value = savedTranslateY.value + e.translationY;
}
})
.onEnd((e) => {
if (savedScale.value <= 1) {
const absY = Math.abs(e.translationY);
const absX = Math.abs(e.translationX);
if (absY > 30 && absY > absX) {
runOnJS(handleSwipeVertical)(e.translationY > 0 ? 'down' : 'up');
}
translateX.value = withSpring(0, SPRING_CONFIG);
translateY.value = withSpring(0, SPRING_CONFIG);
}
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
});
const zoomGestures = Gesture.Simultaneous(pinch, pan);
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.maxDuration(250)
.onEnd(() => {
if (scale.value > 1) {
scale.value = withTiming(1, { duration: 200 });
translateX.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(0, { duration: 200 });
savedScale.value = 1;
savedTranslateX.value = 0;
savedTranslateY.value = 0;
} else {
scale.value = withTiming(DOUBLE_TAP_SCALE, { duration: 200 });
savedScale.value = DOUBLE_TAP_SCALE;
}
});
const singleTap = Gesture.Tap()
.maxDuration(250)
.onEnd(() => {
if (scale.value <= 1) {
runOnJS(handleClose)();
}
});
const taps = Gesture.Exclusive(doubleTap, singleTap);
const composed = Gesture.Race(zoomGestures, taps);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ scale: scale.value },
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<View style={styles.container}>
<GestureDetector gesture={composed}>
<Animated.Image
source={{ uri }}
style={[styles.image, { width, height }, animatedStyle]}
resizeMode="contain"
/>
</GestureDetector>
{onClose && (
<Pressable style={styles.closeButton} onPress={onClose}>
<View style={styles.closeIcon}>
<View style={[styles.closeLine, styles.closeLine1]} />
<View style={[styles.closeLine, styles.closeLine2]} />
</View>
</Pressable>
)}
</View>
);
}
const CLOSE_SIZE = 36;
const CLOSE_LINE = 20;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
},
image: {
overflow: 'hidden',
},
closeButton: {
position: 'absolute',
top: 56,
right: 16,
width: CLOSE_SIZE,
height: CLOSE_SIZE,
borderRadius: CLOSE_SIZE / 2,
backgroundColor: 'rgba(255,255,255,0.25)',
justifyContent: 'center',
alignItems: 'center',
},
closeIcon: {
width: CLOSE_LINE,
height: CLOSE_LINE,
justifyContent: 'center',
alignItems: 'center',
},
closeLine: {
position: 'absolute',
width: CLOSE_LINE,
height: 2,
backgroundColor: '#fff',
borderRadius: 1,
},
closeLine1: {
transform: [{ rotate: '45deg' }],
},
closeLine2: {
transform: [{ rotate: '-45deg' }],
},
});
-34
View File
@@ -1,34 +0,0 @@
export const CURRENT_ONBOARDING_VERSION = 3;
export type OnboardingAction = {
type: 'pick_directory';
label: string;
};
export type OnboardingStep = {
id: string;
version: number;
title: string;
description: string;
icon: string;
action?: OnboardingAction;
condition?: 'has_no_folders';
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: 'welcome',
version: 3,
title: 'Bienvenue sur Dot.',
description: 'Votre espace document personnel, toujours accessible.',
icon: 'waving-hand',
},
{
id: 'select_folders',
version: 3,
title: 'Ajoutez vos dossiers',
description: 'Sélectionnez les dossiers que vous souhaitez synchroniser avec Dot.\n\nAjoutez-en autant que vous voulez, vous pourrez les gérer plus tard.',
icon: 'create-new-folder',
action: { type: 'pick_directory', label: 'Ajouter un dossier' },
},
];
-23
View File
@@ -1,23 +0,0 @@
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.17:8080/api/v1';
export const ENDPOINTS = {
RESOURCES: '/resources',
RESOURCE: '/resources',
UPLOAD: '/resources/upload',
MOVE: '/resources/move',
FOLDERS: '/resources/folders',
VARIANT: '/variants',
DEDUP_CHECK: '/resources/dedup-check',
OCR_JOBS: '/ocr/jobs',
EVENTS: '/events',
HEALTH: '/health',
AUTH_LOGIN: '/auth/login',
AUTH_REGISTER: '/auth/register',
AUTH_REFRESH: '/auth/refresh',
AUTH_LOGOUT: '/auth/logout',
DEVICES: '/devices',
SYNC_PULL: '/sync/pull',
SYNC_PUSH: '/sync/push',
SHARE: '/resources/:id/share',
ACCESS: '/resources/:id/access',
} as const;
-98
View File
@@ -1,98 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { apiClient } from '../api/client';
import { tokenStorage } from '../api/secureStorage';
import { ENDPOINTS } from '../constants/api';
import { AuthResponse, User } from '../types';
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadUser();
}, []);
async function loadUser() {
try {
const stored = await tokenStorage.getUser();
if (stored) {
const userData = JSON.parse(stored) as User;
setUser(userData);
const accessToken = await tokenStorage.getAccessToken();
if (accessToken) {
apiClient.setAccessToken(accessToken);
}
}
} catch {
await tokenStorage.deleteUser();
} finally {
setIsLoading(false);
}
}
async function login(username: string, password: string) {
const response = await apiClient.post<AuthResponse>(ENDPOINTS.AUTH_LOGIN, {
username,
password,
});
apiClient.setAccessToken(response.access_token);
await tokenStorage.setAccessToken(response.access_token);
await tokenStorage.setRefreshToken(response.refresh_token);
await tokenStorage.setUser(JSON.stringify(response.user));
setUser(response.user);
}
async function register(username: string, password: string) {
const response = await apiClient.post<AuthResponse>(ENDPOINTS.AUTH_REGISTER, {
username,
password,
});
apiClient.setAccessToken(response.access_token);
await tokenStorage.setAccessToken(response.access_token);
await tokenStorage.setRefreshToken(response.refresh_token);
await tokenStorage.setUser(JSON.stringify(response.user));
setUser(response.user);
}
async function logout() {
try {
const refreshToken = await tokenStorage.getRefreshToken();
if (refreshToken) {
await apiClient.post(ENDPOINTS.AUTH_LOGOUT, { refresh_token: refreshToken });
}
} catch {}
apiClient.setAccessToken(null);
await tokenStorage.deleteAccessToken();
await tokenStorage.deleteRefreshToken();
await tokenStorage.deleteUser();
setUser(null);
}
return (
<AuthContext.Provider value={{ user, isLoading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
-46
View File
@@ -1,46 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useDeviceRegistration } from '../hooks/useDeviceRegistration';
import { useAuth } from './AuthContext';
interface DeviceContextType {
isRegistered: boolean | null;
isLoading: boolean;
deviceName: string;
register: (name: string) => Promise<{ id: string; device_name: string; role: string }>;
}
const DeviceContext = createContext<DeviceContextType | undefined>(undefined);
export function DeviceProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const registration = useDeviceRegistration();
const { isRegistered, isLoading, device, register } = registration;
const value: DeviceContextType = user
? {
isRegistered,
isLoading,
deviceName: device?.name ?? '',
register,
}
: {
isRegistered: null,
isLoading: false,
deviceName: '',
register: async () => { throw new Error('Not authenticated'); },
};
return (
<DeviceContext.Provider value={value}>
{children}
</DeviceContext.Provider>
);
}
export function useDevice() {
const context = useContext(DeviceContext);
if (!context) {
throw new Error('useDevice must be used within a DeviceProvider');
}
return context;
}
-105
View File
@@ -1,105 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useCallback } from 'react';
import { apiClient } from '../api/client';
import { useAuth } from './AuthContext';
type OcrDoneCallback = (resourceId: string) => void;
type ResourceCreatedCallback = (resourceId: string) => void;
interface SseContextValue {
onOcrDone: (cb: OcrDoneCallback) => () => void;
onResourceCreated: (cb: ResourceCreatedCallback) => () => void;
}
const SseContext = createContext<SseContextValue>({
onOcrDone: () => () => {},
onResourceCreated: () => () => {},
});
export function SseProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const ocrListenersRef = useRef<Set<OcrDoneCallback>>(new Set());
const resourceListenersRef = useRef<Set<ResourceCreatedCallback>>(new Set());
const cancelRef = useRef<() => void>(() => {});
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const connect = useCallback(() => {
if (!user) return;
let cancelled = false;
const start = async () => {
try {
const cancel = await apiClient.subscribeEvents(
(event, data) => {
if (event === 'ocr_done') {
ocrListenersRef.current.forEach((cb) => cb(data));
} else if (event === 'resource.created') {
resourceListenersRef.current.forEach((cb) => cb(data));
}
},
() => {
reconnectTimeoutRef.current = setTimeout(connect, 3000);
},
);
if (cancelled) {
cancel();
} else {
cancelRef.current = cancel;
}
} catch {
reconnectTimeoutRef.current = setTimeout(connect, 3000);
}
};
start();
return () => {
cancelled = true;
};
}, [user]);
useEffect(() => {
const cleanup = connect();
return () => {
cleanup?.();
cancelRef.current();
clearTimeout(reconnectTimeoutRef.current);
};
}, [connect]);
const onOcrDone = useCallback((cb: OcrDoneCallback) => {
ocrListenersRef.current.add(cb);
return () => {
ocrListenersRef.current.delete(cb);
};
}, []);
const onResourceCreated = useCallback((cb: ResourceCreatedCallback) => {
resourceListenersRef.current.add(cb);
return () => {
resourceListenersRef.current.delete(cb);
};
}, []);
return (
<SseContext.Provider value={{ onOcrDone, onResourceCreated }}>
{children}
</SseContext.Provider>
);
}
export function useOcrDone(onDone?: OcrDoneCallback) {
const ctx = useContext(SseContext);
useEffect(() => {
if (onDone) return ctx.onOcrDone(onDone);
}, [onDone, ctx]);
return { onOcrDone: ctx.onOcrDone };
}
export function useResourceCreated(onCreated?: ResourceCreatedCallback) {
const ctx = useContext(SseContext);
useEffect(() => {
if (onCreated) return ctx.onResourceCreated(onCreated);
}, [onCreated, ctx]);
return { onResourceCreated: ctx.onResourceCreated };
}
-203
View File
@@ -1,203 +0,0 @@
import { useEffect, useCallback, useRef } from 'react';
import { createMMKV } from 'react-native-mmkv';
import { useQueryClient } from '@tanstack/react-query';
import { File, UploadType } from 'expo-file-system';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { safDirectory } from '../services/safDirectory';
import { fileStore } from '../services/fileStore';
import { activeUploadUris } from '../services/uploadQueue';
import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError } from '../types';
import {
setIsSyncing,
setSyncProgress,
isSyncLoopRunning,
setSyncLoopRunning,
requestSyncCancel,
isSyncCancelRequested,
consumeSyncCancel,
} from './useSyncQueue';
const MAX_RETRIES = 5;
const RETRY_MMKV_ID = 'vaultdrop-sync-retries';
const retryStorage = createMMKV({ id: RETRY_MMKV_ID });
function getRetryCount(fileId: string): number {
return retryStorage.getNumber(`${fileId}_retries`) ?? 0;
}
function incrementRetry(fileId: string): number {
const count = getRetryCount(fileId) + 1;
retryStorage.set(`${fileId}_retries`, count);
return count;
}
function resetRetry(fileId: string) {
retryStorage.remove(`${fileId}_retries`);
}
interface UploadResult {
name: string;
id: string;
}
async function uploadFile(file: { uri: string; type: string; name: string }): Promise<UploadResult> {
const fsFile = new File(file.uri);
const headers: Record<string, string> = {};
const token = apiClient.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
httpMethod: 'POST',
uploadType: UploadType.MULTIPART,
fieldName: 'file',
mimeType: file.type,
headers,
});
if (result.status >= 400) {
let message = 'Upload failed';
try {
const body: ApiError = JSON.parse(result.body);
message = body.error?.message || message;
} catch {
message = result.body || message;
}
throw new Error(message);
}
const body = JSON.parse(result.body);
const items = body.data ?? body;
const item = Array.isArray(items) ? items[0] : items;
return item as UploadResult;
}
function canSyncBasedOnNetwork(netInfo: NetInfoState, cellularAllowed: boolean): boolean {
if (!netInfo.isConnected) return false;
if (!cellularAllowed && netInfo.type !== 'wifi') return false;
return true;
}
let autoSyncLoopStarted = false;
export function useAutoSync() {
const queryClient = useQueryClient();
const isRunning = useRef(false);
const getPendingFiles = useCallback((): Array<ReturnType<typeof fileStore.getAllLocal>[number]> => {
return fileStore.getAllLocal().filter(
(entry) => !entry.backendId && entry.syncStatus === 'local' && entry.localUri
);
}, []);
const cancelSync = useCallback(() => {
requestSyncCancel();
}, []);
const runPendingSync = useCallback(async (pendingFiles: Array<ReturnType<typeof fileStore.getAllLocal>[number]>) => {
if (isRunning.current) return;
if (isSyncLoopRunning()) return;
const globalCellular = safDirectory.getGlobalSyncCellular();
const netInfo = await NetInfo.fetch();
if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return;
isRunning.current = true;
setSyncLoopRunning(true);
try {
setIsSyncing(true);
consumeSyncCancel();
const progressFiles = pendingFiles.map((f) => ({ id: f.id, name: f.name }));
for (let index = 0; index < pendingFiles.length; index++) {
if (isSyncCancelRequested()) break;
const entry = pendingFiles[index];
setSyncProgress({ files: progressFiles, currentIndex: index });
const uri = entry.localUri;
if (!uri || activeUploadUris.has(uri)) continue;
activeUploadUris.add(uri);
try {
const uploaded = await uploadFile({
uri,
type: entry.mimeType,
name: entry.name,
});
resetRetry(entry.id);
fileStore.updatePartial(entry.id, {
backendId: uploaded.id,
syncStatus: 'synced',
source: 'synced',
});
} catch (err) {
const retries = incrementRetry(entry.id);
if (retries >= MAX_RETRIES) {
fileStore.updatePartial(entry.id, {
syncStatus: 'error',
});
resetRetry(entry.id);
}
} finally {
activeUploadUris.delete(uri);
}
}
if (consumeSyncCancel()) {
return;
}
queryClient.invalidateQueries({ queryKey: ['resources'] });
} finally {
setSyncProgress(null);
setIsSyncing(false);
isRunning.current = false;
setSyncLoopRunning(false);
}
}, [queryClient]);
const checkAndSync = useCallback(async () => {
const globalMode = safDirectory.getGlobalSyncMode();
if (globalMode === 'off') return;
let pendingFiles = getPendingFiles();
if (globalMode === 'manual') {
const allFolders = safDirectory.getAll();
const autoFolderIds = new Set(
allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id)
);
pendingFiles = pendingFiles.filter(
(entry) => entry.parentResourceId && autoFolderIds.has(entry.parentResourceId)
);
}
if (pendingFiles.length === 0) return;
await runPendingSync(pendingFiles);
}, [getPendingFiles, runPendingSync]);
const syncManually = useCallback(async () => {
const pendingFiles = getPendingFiles();
if (pendingFiles.length === 0) return;
await runPendingSync(pendingFiles);
}, [getPendingFiles, runPendingSync]);
useEffect(() => {
if (autoSyncLoopStarted) return;
autoSyncLoopStarted = true;
const timeout = setTimeout(() => {
checkAndSync();
}, 5_000);
const interval = setInterval(checkAndSync, 30_000);
return () => { clearTimeout(timeout); clearInterval(interval); autoSyncLoopStarted = false; };
}, [checkAndSync]);
return { triggerSync: checkAndSync, syncManually, cancelSync };
}
-71
View File
@@ -1,71 +0,0 @@
import { useCallback } from 'react';
import { createMMKV, useMMKVObject } from 'react-native-mmkv';
import { Batch } from '../types';
const storage = createMMKV({ id: 'vaultdrop-batches' });
export function useBatchStore() {
const [batchIds, setBatchIds] = useMMKVObject<string[]>('batch-ids', storage);
const saveBatch = useCallback((batch: Batch) => {
storage.set(`batch_${batch.id}`, JSON.stringify(batch));
const current = batchIds ?? [];
if (!current.includes(batch.id)) {
setBatchIds([batch.id, ...current]);
}
}, [batchIds, setBatchIds]);
const getBatch = useCallback((id: string): Batch | undefined => {
const raw = storage.getString(`batch_${id}`);
if (!raw) return undefined;
return JSON.parse(raw) as Batch;
}, []);
const getAllBatches = useCallback((): Batch[] => {
return (batchIds ?? [])
.map((id) => getBatch(id))
.filter((b): b is Batch => b !== undefined);
}, [batchIds, getBatch]);
const addTagToBatch = useCallback((batchId: string, tag: string) => {
const batch = getBatch(batchId);
if (!batch) return;
if (batch.tags.includes(tag)) return;
batch.tags = [...batch.tags, tag];
storage.set(`batch_${batchId}`, JSON.stringify(batch));
}, [getBatch]);
const removeTagFromBatch = useCallback((batchId: string, tag: string) => {
const batch = getBatch(batchId);
if (!batch) return;
batch.tags = batch.tags.filter((t) => t !== tag);
storage.set(`batch_${batchId}`, JSON.stringify(batch));
}, [getBatch]);
const deleteBatch = useCallback((batchId: string) => {
storage.set(`batch_${batchId}`, undefined as any);
storage.remove(`batch_${batchId}` as any);
setBatchIds((batchIds ?? []).filter((id) => id !== batchId));
}, [batchIds, setBatchIds]);
const removePhotoFromBatch = useCallback((batchId: string, photoId: string) => {
const batch = getBatch(batchId);
if (!batch) return;
batch.photos = batch.photos.filter((p) => p.id !== photoId);
if (batch.photos.length === 0) {
deleteBatch(batchId);
} else {
storage.set(`batch_${batchId}`, JSON.stringify(batch));
}
}, [getBatch, deleteBatch]);
return {
saveBatch,
getBatch,
getAllBatches,
addTagToBatch,
removeTagFromBatch,
deleteBatch,
removePhotoFromBatch,
};
}
-133
View File
@@ -1,133 +0,0 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useCameraDevice, useCameraPermission, usePhotoOutput, type TorchMode, type CameraRef } from 'react-native-vision-camera';
import { useUpload } from './useUpload';
import { CapturedPhoto } from '../types';
type CaptureStatus = 'idle' | 'capturing' | 'uploading' | 'success' | 'error';
export function useCameraCapture() {
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const cameraRef = useRef<CameraRef>(null);
const [captureStatus, setCaptureStatus] = useState<CaptureStatus>('idle');
const [captureError, setCaptureError] = useState<string>();
const [torchMode, setTorchMode] = useState<TorchMode>('off');
const [cameraReady, setCameraReady] = useState(false);
const [capturedPhotos, setCapturedPhotos] = useState<CapturedPhoto[]>([]);
const upload = useUpload();
const photoOutput = usePhotoOutput();
useEffect(() => {
if (!hasPermission) {
requestPermission();
}
}, [hasPermission, requestPermission]);
const isActive = hasPermission && !!device && captureStatus !== 'uploading';
const toggleTorch = useCallback(async () => {
const next = torchMode === 'off' ? 'on' : 'off';
setTorchMode(next);
if (cameraReady && cameraRef.current) {
try {
const controller = cameraRef.current.controller;
if (controller?.device.hasTorch) {
await controller.setTorchMode(next);
}
} catch {
setTorchMode(torchMode);
}
}
}, [torchMode, cameraReady]);
const onStarted = useCallback(() => {
setCameraReady(true);
if (torchMode === 'on' && cameraRef.current) {
const controller = cameraRef.current.controller;
controller?.setTorchMode('on').catch(() => {});
}
}, [torchMode]);
const onStopped = useCallback(() => {
setCameraReady(false);
}, []);
const addCapturedPhoto = useCallback((photo: CapturedPhoto) => {
setCapturedPhotos((prev) => [...prev, photo]);
}, []);
const removeCapturedPhoto = useCallback((id: string) => {
setCapturedPhotos((prev) => prev.filter((p) => p.id !== id));
}, []);
const clearCapturedPhotos = useCallback(() => {
setCapturedPhotos([]);
}, []);
const capturePhoto = useCallback(async () => {
if (!photoOutput) return;
const photoId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
try {
setCaptureStatus('capturing');
setCaptureError(undefined);
const { filePath } = await photoOutput.capturePhotoToFile({}, {});
setCaptureStatus('uploading');
const capturedPhoto: CapturedPhoto = {
id: photoId,
filePath,
uri: 'file://' + filePath,
};
addCapturedPhoto(capturedPhoto);
const result = await upload.mutateAsync([
{ uri: capturedPhoto.uri, type: 'image/jpeg', name: `scan_${photoId}.jpg` },
]);
if (result.errors.length > 0) {
setCaptureStatus('error');
setCaptureError(result.errors[0].message);
} else {
capturedPhoto.uploadedId = result.uploaded[0]?.id;
capturedPhoto.uploadedAt = new Date().toISOString();
setCaptureStatus('success');
}
} catch (err) {
setCaptureStatus('error');
setCaptureError(err instanceof Error ? err.message : 'Erreur lors de la capture');
}
setTimeout(() => {
setCaptureStatus('idle');
setCaptureError(undefined);
}, 1500);
}, [photoOutput, upload, addCapturedPhoto]);
return {
cameraRef,
hasPermission,
requestPermission,
device,
photoOutput,
capturePhoto,
captureStatus,
captureError,
isActive,
torchMode,
toggleTorch,
onStarted,
onStopped,
capturedPhotos,
removeCapturedPhoto,
clearCapturedPhotos,
capturedCount: capturedPhotos.length,
};
}
-12
View File
@@ -1,12 +0,0 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
-244
View File
@@ -1,244 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import * as FileSystem from 'expo-file-system/legacy';
import { safDirectory, type StoredFolder } from '../services/safDirectory';
import { downloadRegistry } from '../services/downloadRegistry';
import { useFileWatcher } from './useFileWatcher';
import { FileDetectedEvent } from '../modules/expo-download-detect';
export interface DeviceFile {
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
folderId?: string;
}
function guessMimeType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
zip: 'application/zip',
rar: 'application/x-rar-compressed',
mp4: 'video/mp4',
mp3: 'audio/mpeg',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
};
return map[ext] ?? 'application/octet-stream';
}
function eventToDeviceFile(event: FileDetectedEvent): DeviceFile {
return {
id: event.id,
uri: event.uri,
name: event.name,
mimeType: event.mimeType,
size: event.size,
createdAt: new Date(event.createdAt).toISOString(),
};
}
const SKIP_SUBDIRS = new Set([
'Android', 'android', 'data', 'obb', 'cache',
'.thumbnails', '.Trash', 'lost+found',
'LOST.DIR', 'System Volume Information',
'com.android', '.cache', 'tmp', '.tmp',
]);
function shouldSkipDir(name: string): boolean {
if (name.startsWith('.')) return true;
return SKIP_SUBDIRS.has(name);
}
async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
try {
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri);
const files: DeviceFile[] = [];
for (const entryUri of entries) {
const parts = entryUri.split('/');
const name = decodeURIComponent(parts[parts.length - 1]);
if (name.startsWith('.')) continue;
files.push({
id: `saf_${folder.id}_${entryUri}`,
uri: entryUri,
name,
mimeType: guessMimeType(name),
size: 0,
createdAt: new Date().toISOString(),
folderId: folder.id,
});
}
return files;
} catch (err) {
return [];
}
}
export async function scanSubdirectories(
baseUri: string,
depth: number = 0,
maxDepth: number = 3,
discovered: Array<{ uri: string; name: string; parentUri: string }> = []
): Promise<Array<{ uri: string; name: string; parentUri: string }>> {
if (depth >= maxDepth || discovered.length >= 300) return discovered;
try {
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(baseUri);
for (const entryUri of entries) {
const parts = entryUri.split('/');
const name = decodeURIComponent(parts[parts.length - 1]);
if (shouldSkipDir(name)) continue;
try {
await FileSystem.StorageAccessFramework.readDirectoryAsync(entryUri);
discovered.push({ uri: entryUri, name, parentUri: baseUri });
await scanSubdirectories(entryUri, depth + 1, maxDepth, discovered);
} catch {}
}
} catch {}
return discovered;
}
export function useDeviceFiles() {
const [files, setFiles] = useState<DeviceFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
const [discovered, setDiscovered] = useState(() => safDirectory.getDiscovered());
const { newFiles, clearNewFiles } = useFileWatcher();
const scanVisibleFolders = useCallback(async () => {
const visibleFolders = safDirectory.getVisibleFolders().filter((f) => f.source !== 'media-library');
if (visibleFolders.length === 0) return;
const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
const safFiles = results.flat();
setFiles((prev) => {
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
const mediaOnly = prev.filter((f) => !f.folderId);
const merged = [...mediaOnly];
for (const f of safFiles) {
if (!existing.has(f.id)) merged.push(f);
}
return merged;
});
}, []);
const loadRegistryFiles = useCallback(() => {
const registryEntries = downloadRegistry.getAll();
const registryFiles: DeviceFile[] = registryEntries.map((entry) => ({
id: entry.id,
uri: entry.uri,
name: entry.name,
mimeType: entry.mimeType,
size: entry.size,
createdAt: new Date(entry.createdAt).toISOString(),
}));
setFiles((prev) => {
const existing = new Set(prev.map((f) => f.id));
const merged = [...prev];
for (const f of registryFiles) {
if (!existing.has(f.id)) merged.push(f);
}
return merged;
});
}, []);
const pickAndScanRecursive = useCallback(async (): Promise<{ addedFolders: number }> => {
try {
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!result.granted) return { addedFolders: 0 };
const dirUri = result.directoryUri;
const parts = dirUri.split('/');
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
safDirectory.addFolder(dirUri, dirName);
const subdirs = await scanSubdirectories(dirUri);
if (subdirs.length > 0) {
safDirectory.addBatchFolders(
subdirs.map((d) => ({ uri: d.uri, name: d.name, source: 'recursive', parentUri: d.parentUri }))
);
}
setFolders(safDirectory.getAll());
if (!discovered) {
safDirectory.setDiscovered();
setDiscovered(true);
}
await scanVisibleFolders();
return { addedFolders: 1 + subdirs.length };
} catch (err) {
return { addedFolders: 0 };
}
}, [discovered, scanVisibleFolders]);
const refreshFolders = useCallback(() => {
setFolders(safDirectory.getAll());
}, []);
const rescan = useCallback(async () => {
setIsLoading(true);
try {
await scanVisibleFolders();
loadRegistryFiles();
} finally {
setIsLoading(false);
}
}, [scanVisibleFolders, loadRegistryFiles]);
// Handle new files detected by native module
useEffect(() => {
if (newFiles.length === 0) return;
downloadRegistry.addBatch(newFiles);
const newDeviceFiles = newFiles.map(eventToDeviceFile);
setFiles((prev) => {
const existing = new Set(prev.map((f) => f.id));
const merged = [...prev];
for (const f of newDeviceFiles) {
if (!existing.has(f.id)) merged.push(f);
}
return merged;
});
clearNewFiles();
}, [newFiles, clearNewFiles]);
// Initial load
useEffect(() => {
scanVisibleFolders();
loadRegistryFiles();
}, [scanVisibleFolders, loadRegistryFiles]);
return {
files, isLoading,
rescan,
pickAndScanRecursive,
folders, refreshFolders,
discovered,
};
}
-103
View File
@@ -1,103 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { Device as DeviceType } from '../types';
const DEVICE_ID_KEY = 'vaultdrop_device_id';
const DEVICE_NAME_KEY = 'vaultdrop_device_name';
const DEVICE_SERVER_ID_KEY = 'vaultdrop_device_server_id';
function generateDefaultDeviceName(): string {
const constants = Platform.constants as Record<string, unknown>;
const brand = String(constants?.Manufacturer ?? '');
const model = String(constants?.Model ?? '');
const suffix = Math.random().toString(36).slice(2, 6);
const base = [brand, model].filter(Boolean).join(' ') || Platform.OS;
return `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${base} (${suffix})`;
}
async function getOrCreateDeviceId(): Promise<string> {
let deviceId = await SecureStore.getItemAsync(DEVICE_ID_KEY);
if (!deviceId) {
deviceId = `device_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId);
}
return deviceId;
}
export async function getStoredDeviceServerId(): Promise<string | null> {
return SecureStore.getItemAsync(DEVICE_SERVER_ID_KEY);
}
export async function getStoredDeviceName(): Promise<string | null> {
return SecureStore.getItemAsync(DEVICE_NAME_KEY);
}
export function useDeviceRegistration() {
const [device, setDevice] = useState<{ localId: string; serverId: string | null; name: string } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRegistered, setIsRegistered] = useState<boolean | null>(null);
const register = useCallback(async (deviceName: string) => {
const localId = await getOrCreateDeviceId();
const result = await apiClient.post<{ id: string; device_name: string; role: string }>(ENDPOINTS.DEVICES, {
device_name: deviceName,
});
await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, result.id);
await SecureStore.setItemAsync(DEVICE_NAME_KEY, result.device_name);
setDevice({ localId, serverId: result.id, name: result.device_name });
setIsRegistered(true);
return result;
}, []);
const checkRegistration = useCallback(async () => {
try {
const localId = await getOrCreateDeviceId();
const storedName = await getStoredDeviceName();
try {
const devices = await apiClient.get<DeviceType[]>(ENDPOINTS.DEVICES);
if (devices.length > 0) {
const existing = devices[0];
await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, existing.id);
const name = storedName || existing.device_name;
setDevice({ localId, serverId: existing.id, name });
setIsRegistered(true);
setIsLoading(false);
return;
}
} catch {
// Not registered yet
}
// Auto-register with a generated name instead of blocking
const autoName = storedName || generateDefaultDeviceName();
try {
await register(autoName);
} catch {
setDevice({ localId, serverId: null, name: autoName });
setIsRegistered(false);
}
} catch {
setIsRegistered(false);
} finally {
setIsLoading(false);
}
}, [register]);
useEffect(() => {
checkRegistration();
}, [checkRegistration]);
return {
device,
isLoading,
isRegistered,
register,
checkRegistration,
};
}
-32
View File
@@ -1,32 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { Platform } from 'react-native';
import { ExpoDownloadDetectModule, FileDetectedEvent } from '../modules/expo-download-detect';
export function useFileWatcher() {
const [newFiles, setNewFiles] = useState<FileDetectedEvent[]>([]);
const [isSupported] = useState(() => Platform.OS === 'android');
const clearNewFiles = useCallback(() => {
setNewFiles([]);
}, []);
useEffect(() => {
if (!isSupported) return;
ExpoDownloadDetectModule.startWatching();
const subscription = ExpoDownloadDetectModule.addListener('onNewFile', (event: FileDetectedEvent) => {
setNewFiles((prev) => {
if (prev.some((f) => f.id === event.id)) return prev;
return [...prev, event];
});
});
return () => {
subscription.remove();
ExpoDownloadDetectModule.stopWatching();
};
}, [isSupported]);
return { newFiles, clearNewFiles, isSupported };
}
-384
View File
@@ -1,384 +0,0 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types';
import { fileStore } from '../services/fileStore';
import { actionQueue } from '../services/actionQueue';
import { useNetworkStatus } from './useNetworkStatus';
function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): UnifiedFileItem | null {
if (!record) return null;
return {
id: record.id,
backendResourceId: record.backendId ?? undefined,
name: record.name,
mimeType: record.mimeType,
size: record.size,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
source: record.source as UnifiedFileItem['source'],
syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'],
localUri: record.localUri ?? undefined,
ocrText: record.ocrText ?? undefined,
tags: record.tags ?? [],
isFolder: record.isFolder === 1,
parentResourceId: record.parentResourceId ?? undefined,
ownerId: record.ownerId ?? undefined,
thumbnailUrl: record.thumbnailUrl ?? undefined,
thumbnailLocal: record.thumbnailLocal ?? undefined,
isDeviceFile: record.source === 'local' && !record.backendId,
};
}
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
const queryKey = parentId
? ['resources', parentId, page, limit]
: ['resources', 'root', page, limit];
const { isOnline } = useNetworkStatus();
return useQuery({
queryKey,
queryFn: async () => {
if ( !isOnline ) {
if ( parentId ) {
const children = fileStore.getChildrenByParent(parentId);
return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: children.length },
};
}
const cached = fileStore.getRootFiles();
const localDeviceFiles = fileStore.getLocalDeviceFiles();
const validFiles = cached.files.filter(
(f) => !f.backendId || returnedIds.has(f.backendId) || f.source === 'local',
);
const validIds = new Set(validFiles.map((f) => f.id));
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
const mergedFiles = [...validFiles, ...extraLocal];
return {
data: mergedFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: ( cached.total) + extraLocal.length },
};
}
if (parentId) {
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
`${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
);
fileStore.mergeFromBackend(
backendRes.data.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
ocrText: f.ocrText,
tags: f.tags,
isFolder: f.isFolder,
parentResourceId: f.parentResourceId,
ownerId: f.ownerId,
thumbnailUrl: f.thumbnailUrl,
})),
);
const children = fileStore.getChildrenByParent(parentId);
return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: backendRes.meta?.total ?? children.length },
};
}
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
);
const returnedIds = new Set(backendRes.data.map((f) => f.id));
fileStore.mergeFromBackend(
backendRes.data.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
ocrText: f.ocrText,
tags: f.tags,
isFolder: f.isFolder,
parentResourceId: f.parentResourceId,
ownerId: f.ownerId,
thumbnailUrl: f.thumbnailUrl,
})),
);
const cached = fileStore.getRootFiles();
const localDeviceFiles = fileStore.getLocalDeviceFiles();
const validFiles = cached.files.filter(
(f) => !f.backendId || returnedIds.has(f.backendId) || f.source === 'local',
);
const validIds = new Set(validFiles.map((f) => f.id));
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
const mergedFiles = [...validFiles, ...extraLocal];
return {
data: mergedFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: (backendRes.meta?.total ?? cached.total) + extraLocal.length },
};
},
placeholderData: keepPreviousData,
initialData: () => {
if (!parentId) {
const cached = fileStore.getRootFiles();
const localDeviceFiles = fileStore.getLocalDeviceFiles();
const validIds = new Set(cached.files.map((f) => f.id));
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
const allFiles = [...cached.files, ...extraLocal];
if (allFiles.length === 0) return undefined;
return {
data: allFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: cached.total + extraLocal.length },
};
}
const children = fileStore.getChildrenByParent(parentId);
if (children.length === 0) return undefined;
return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: children.length },
};
},
staleTime: 30_000,
});
}
export function useFile(id: string) {
return useQuery({
queryKey: ['resources', id],
queryFn: async () => {
const res = await apiClient.get<{ data: FileItem }>(`${ENDPOINTS.RESOURCES}/${id}?thumbnail=thumbnail_small`);
return res.data;
},
enabled: !!id,
});
}
export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const record = fileStore.getByBackendId(id) ?? fileStore.getById(id);
if (record?.backendId) {
actionQueue.enqueue('delete', { backendId: record.backendId }, record.backendId);
}
fileStore.deleteByBackendId(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
export function useAddTags() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ fileId, tags }: { fileId: string; tags: string[] }) => {
const record = fileStore.getById(fileId) ?? fileStore.getByBackendId(fileId);
if (record) {
const existingTags = record.tags ?? [];
const newTags = [...existingTags, ...tags.map((t) => ({ id: t, tag_name: t }))];
const uniqueTags = newTags.filter((t, i, arr) => arr.findIndex((x) => x.tag_name === t.tag_name) === i);
fileStore.updatePartial(record.id, {});
actionQueue.enqueue('tag_add', { fileId: record.backendId ?? record.id, tags }, record.backendId ?? record.id);
}
return Promise.resolve();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
export function useMoveResources() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ resourceIds, parentResourceId }: { resourceIds: string[]; parentResourceId: string | null }) => {
const backendIds: string[] = [];
for (const id of resourceIds) {
const record = fileStore.getById(id) ?? fileStore.getByBackendId(id);
if (record?.backendId) {
backendIds.push(record.backendId);
fileStore.updatePartial(record.id, { parentResourceId: parentResourceId ?? null });
}
}
if (backendIds.length > 0) {
actionQueue.enqueue('move', { resourceIds: backendIds, parentResourceId });
}
return Promise.resolve();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
export function useFolders() {
return useQuery({
queryKey: ['folders'],
queryFn: async () => {
const backendRes = await apiClient.get<{ data: FileItem[] }>(ENDPOINTS.FOLDERS);
fileStore.mergeFromBackend(
backendRes.data.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
ocrText: f.ocrText,
tags: f.tags,
isFolder: f.isFolder,
parentResourceId: f.parentResourceId,
ownerId: f.ownerId,
thumbnailUrl: f.thumbnailUrl,
})),
);
return fileStore.getAllFolders();
},
initialData: () => {
const folders = fileStore.getAllFolders();
return folders.length > 0 ? folders : undefined;
},
staleTime: 60_000,
});
}
export function useCreateFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ name, parentResourceId }: { name: string; parentResourceId?: string }) => {
const now = new Date().toISOString();
const localId = `local_folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
fileStore.upsert({
id: localId,
backendId: null,
name,
mimeType: 'inode/directory',
size: 0,
source: 'local',
localUri: null,
syncStatus: 'local',
parentResourceId: parentResourceId ?? null,
isFolder: 1,
ocrText: null,
thumbnailUrl: null,
ownerId: null,
createdAt: now,
updatedAt: now,
lastSyncedAt: null,
});
actionQueue.enqueue('create_folder', { name, parentResourceId }, localId);
return { id: localId, name };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
queryClient.invalidateQueries({ queryKey: ['folders'] });
},
});
}
export function useFilesByParent(parentId: string) {
return useFiles(parentId);
}
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<{ url: string }>(
`${ENDPOINTS.RESOURCES}/${file.backendResourceId ?? file.id}`,
);
const { downloadAsync, documentDirectory, makeDirectoryAsync } = await import('expo-file-system/legacy');
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true });
let hash = 0;
for (let i = 0; i < file.name.length; i++) {
hash = ((hash << 5) - hash + file.name.charCodeAt(i)) | 0;
}
const cacheKey = Math.abs(hash).toString(36);
const dot = file.name.lastIndexOf('.');
const ext = dot >= 0 ? file.name.slice(dot) : '';
const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`;
const result = await downloadAsync(res.url, fileUri);
fileStore.upsert({
id: file.backendResourceId ?? file.id,
backendId: file.backendResourceId ?? file.id,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: 'synced',
localUri: result.uri,
syncStatus: 'synced',
parentResourceId: file.parentResourceId ?? null,
isFolder: 0,
ocrText: file.ocrText ?? null,
thumbnailUrl: file.thumbnailUrl ?? null,
ownerId: file.ownerId ?? null,
createdAt: file.createdAt,
updatedAt: file.updatedAt ?? file.createdAt,
lastSyncedAt: new Date().toISOString(),
tags: file.tags,
});
queryClient.invalidateQueries({ queryKey: ['resources'] });
return result.uri;
},
});
}
export function useFreeLocalSpace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (fileIds: string[]) => {
const { deleteAsync } = await import('expo-file-system/legacy');
for (const fid of fileIds) {
const entry = fileStore.getByBackendId(fid);
if (!entry) continue;
if (entry.localUri) {
try {
await deleteAsync(entry.localUri, { idempotent: true });
} catch {}
}
fileStore.markAsCloudOnly(entry.id);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
-110
View File
@@ -1,110 +0,0 @@
import { useMemo, useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useDeviceFiles } from './useDeviceFiles';
import { fileStore } from '../services/fileStore';
import { generateLocalThumbnail } from '../services/thumbnail';
import { UnifiedFileItem } from '../types';
export function useLocalFiles() {
const { files: deviceFiles, isLoading: deviceLoading, rescan, pickAndScanRecursive, folders, refreshFolders, discovered } = useDeviceFiles();
const queryClient = useQueryClient();
const lastDeviceCount = useRef(0);
const thumbnailQueue = useRef<Set<string>>(new Set());
useEffect(() => {
if (deviceFiles.length === 0) return;
if (deviceFiles.length === lastDeviceCount.current) return;
lastDeviceCount.current = deviceFiles.length;
const toMerge = deviceFiles.map((df) => ({
id: df.id,
uri: df.uri,
name: df.name,
mimeType: df.mimeType,
size: df.size,
createdAt: df.createdAt,
folderId: df.folderId,
}));
fileStore.mergeFromDevice(toMerge);
const jobs: Promise<void>[] = [];
for (const df of toMerge) {
if (thumbnailQueue.current.has(df.id)) continue;
const mime = (df.mimeType ?? '').toLowerCase();
if (!mime.startsWith('image/')) continue;
if (fileStore.getById(df.id)?.thumbnailLocal) continue;
thumbnailQueue.current.add(df.id);
jobs.push(
generateLocalThumbnail(df.uri, df.mimeType).then((thumb) => {
try {
if (thumb) fileStore.setThumbnailLocal(df.id, thumb);
} catch {}
}).finally(() => {
thumbnailQueue.current.delete(df.id);
}),
);
}
if (jobs.length > 0) {
Promise.all(jobs).finally(() => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
});
}
}, [deviceFiles, queryClient]);
const localFiles = useMemo(() => {
const registryEntries = fileStore.getAllLocal();
const merged = new Map<string, UnifiedFileItem>();
for (const entry of registryEntries) {
merged.set(entry.id, {
id: entry.id,
backendResourceId: entry.backendId ?? undefined,
name: entry.name,
mimeType: entry.mimeType,
size: entry.size,
createdAt: entry.createdAt,
source: entry.source as UnifiedFileItem['source'],
syncStatus: entry.syncStatus as UnifiedFileItem['syncStatus'],
localUri: entry.localUri ?? undefined,
thumbnailUrl: entry.thumbnailUrl ?? undefined,
thumbnailLocal: entry.thumbnailLocal ?? undefined,
tags: entry.tags ?? [],
isFolder: entry.isFolder === 1,
parentResourceId: entry.parentResourceId ?? undefined,
isDeviceFile: entry.source === 'local' && !entry.backendId,
});
}
for (const df of deviceFiles) {
if (!merged.has(df.id) && !fileStore.isDeleted(df.id)) {
merged.set(df.id, {
id: df.id,
name: df.name,
mimeType: df.mimeType,
size: df.size,
createdAt: df.createdAt,
source: 'local',
syncStatus: 'local',
localUri: df.uri,
tags: [],
isFolder: false,
isDeviceFile: true,
parentResourceId: df.folderId,
});
}
}
return Array.from(merged.values());
}, [deviceFiles]);
return {
localFiles,
isLoading: deviceLoading,
rescan,
pickAndScanRecursive,
folders,
refreshFolders,
discovered,
};
}
-55
View File
@@ -1,55 +0,0 @@
import { useSyncExternalStore, useRef } from 'react';
import NetInfo, { NetInfoState, NetInfoSubscription } from '@react-native-community/netinfo';
type Listener = () => void;
let state: NetInfoState | null = null;
const listeners = new Set<Listener>();
let subscription: NetInfoSubscription | null = null;
function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot(): boolean {
return state?.isConnected ?? true;
}
function initIfNeeded() {
if (subscription) return;
NetInfo.fetch().then((info) => {
state = info;
listeners.forEach((l) => l());
});
subscription = NetInfo.addEventListener((info) => {
state = info;
listeners.forEach((l) => l());
});
}
export interface NetworkStatus {
isOnline: boolean;
isWifi: boolean;
isCellular: boolean;
connectionType: string;
isInternetReachable: boolean | null;
}
export function useNetworkStatus(): NetworkStatus {
const mountRef = useRef(false);
if (!mountRef.current) {
initIfNeeded();
mountRef.current = true;
}
const isConnected = useSyncExternalStore(subscribe, getSnapshot);
return {
isOnline: isConnected,
isWifi: state?.type === 'wifi',
isCellular: state?.type === 'cellular',
connectionType: state?.type ?? 'unknown',
isInternetReachable: state?.isInternetReachable ?? null,
};
}
-55
View File
@@ -1,55 +0,0 @@
import { useState, useCallback } from 'react';
import * as Print from 'expo-print';
export function usePdfGeneration() {
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const generatePdf = useCallback(async (items: { uri: string }[], progressCb?: (pct: number) => void): Promise<string | null> => {
if (items.length === 0) return null;
setGenerating(true);
setProgress(0);
progressCb?.(0);
try {
const imagesHtml = items.map((item) => {
return `<div style="page-break-after: always; display: flex; justify-content: center; align-items: center; height: 100vh;">
<img src="${item.uri}" style="max-width: 100%; max-height: 100vh; object-fit: contain;" />
</div>`;
}).join('');
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #fff; }
@media print {
@page { margin: 0; }
}
</style>
</head>
<body>${imagesHtml}</body>
</html>`;
const { uri } = await Print.printToFileAsync({ html });
setProgress(100);
progressCb?.(100);
return uri;
} catch (err) {
console.error('PDF generation failed:', err);
return null;
} finally {
setGenerating(false);
}
}, []);
return {
generatePdf,
generating,
progress,
};
}
-37
View File
@@ -1,37 +0,0 @@
import { useState, useEffect, useCallback, useSyncExternalStore, useRef } from 'react';
import { actionQueue } from '../services/actionQueue';
import { useNetworkStatus } from './useNetworkStatus';
function subscribe(listener: () => void): () => void {
return actionQueue.subscribe(listener);
}
function getSnapshot(): number {
return actionQueue.getPendingCount();
}
export function usePendingActions() {
const pendingCount = useSyncExternalStore(subscribe, getSnapshot);
const { isOnline } = useNetworkStatus();
const wasOffline = useRef(!isOnline);
useEffect(() => {
if (isOnline && wasOffline.current && pendingCount > 0) {
actionQueue.processAll();
}
wasOffline.current = !isOnline;
}, [isOnline, pendingCount]);
useEffect(() => {
if (pendingCount > 0 && isOnline) {
actionQueue.scheduleRetry(5_000);
}
return () => actionQueue.cancelSchedule();
}, [pendingCount, isOnline]);
return {
pendingCount,
isProcessing: actionQueue.isProcessing(),
processAll: useCallback(() => actionQueue.processAll(), []),
};
}
-28
View File
@@ -1,28 +0,0 @@
import React, { useEffect } from 'react';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { fileStore } from '../services/fileStore';
import { useOcrDone } from '../contexts/SseContext';
function handleOcrDone(resourceId: string) {
apiClient
.get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`)
.then((detail) => {
const ocrText = detail.data?.ocrText;
if (ocrText) {
fileStore.updatePartial(resourceId, { ocrText });
}
})
.catch(() => {});
}
export function SseOcrListener() {
const { onOcrDone } = useOcrDone();
useEffect(() => {
const unsub = onOcrDone(handleOcrDone);
return unsub;
}, [onOcrDone]);
return null;
}
-102
View File
@@ -1,102 +0,0 @@
import { useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy';
import { fileStore } from '../services/fileStore';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { setIsSyncing } from './useSyncQueue';
const SYNC_DIR = `${documentDirectory}synced-files/`;
export function usePullSync() {
const queryClient = useQueryClient();
const isRunning = useRef(false);
const pullNewFiles = useCallback(async () => {
if (isRunning.current) return { pulled: 0 };
isRunning.current = true;
try {
setIsSyncing(true);
let page = 1;
const limit = 100;
let total = 0;
const backendResources: Array<{
id: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
url?: string;
thumbnailUrl?: string;
ownerId?: string;
}> = [];
do {
const res = await apiClient.get<{ data: Array<typeof backendResources[number]>; meta?: { total: number } }>(
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
);
backendResources.push(...(res.data ?? []));
total = res.meta?.total ?? res.data.length;
page++;
} while (backendResources.length < total);
const registry = fileStore.getAllSynced();
const existingBackendIds = new Set(
registry.filter((e) => e.backendId).map((e) => e.backendId)
);
let pulled = 0;
for (const br of backendResources) {
if (existingBackendIds.has(br.id)) continue;
if (br.size === 0) continue;
try {
const detail = await apiClient.get<{ url: string }>(`${ENDPOINTS.RESOURCES}/${br.id}`);
const downloadUrl = detail.url;
await makeDirectoryAsync(SYNC_DIR, { intermediates: true });
const safeName = br.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const fileUri = `${SYNC_DIR}${br.id}_${safeName}`;
const result = await downloadAsync(downloadUrl, fileUri);
fileStore.upsert({
id: br.id,
backendId: br.id,
name: br.name,
mimeType: br.mimeType,
size: br.size,
source: 'synced',
localUri: result.uri,
syncStatus: 'synced',
parentResourceId: null,
isFolder: 0,
ocrText: null,
thumbnailUrl: br.thumbnailUrl ?? null,
ownerId: br.ownerId ?? null,
createdAt: br.createdAt,
updatedAt: br.createdAt,
lastSyncedAt: new Date().toISOString(),
});
pulled++;
} catch {
// skip individual file failures
}
}
if (pulled > 0) {
queryClient.invalidateQueries({ queryKey: ['resources'] });
}
return { pulled };
} finally {
setIsSyncing(false);
isRunning.current = false;
}
}, [queryClient]);
return { pullNewFiles };
}
-17
View File
@@ -1,17 +0,0 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useResourceCreated } from '../contexts/SseContext';
export function SseResourceListener() {
const queryClient = useQueryClient();
const { onResourceCreated } = useResourceCreated();
useEffect(() => {
const unsub = onResourceCreated(() => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
});
return unsub;
}, [onResourceCreated, queryClient]);
return null;
}
-44
View File
@@ -1,44 +0,0 @@
import { useMemo } from 'react';
import { fileStore } from '../services/fileStore';
import { UnifiedFileItem } from '../types';
function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): UnifiedFileItem | null {
if (!record) return null;
return {
id: record.id,
backendResourceId: record.backendId ?? undefined,
name: record.name,
mimeType: record.mimeType,
size: record.size,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
source: record.source as UnifiedFileItem['source'],
syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'],
localUri: record.localUri ?? undefined,
ocrText: record.ocrText ?? undefined,
tags: record.tags ?? [],
isFolder: record.isFolder === 1,
parentResourceId: record.parentResourceId ?? undefined,
ownerId: record.ownerId ?? undefined,
thumbnailUrl: record.thumbnailUrl ?? undefined,
thumbnailLocal: record.thumbnailLocal ?? undefined,
isDeviceFile: record.source === 'local' && !record.backendId,
};
}
export function useSearch(query: string) {
const results = useMemo(() => {
if (!query.trim()) return [];
const records = fileStore.searchFts(query);
if (records.length === 0) {
const fallback = fileStore.search(query);
return fallback.map((r) => recordToUnifiedItem(r)!).filter(Boolean);
}
return records.map((r) => recordToUnifiedItem(r)!).filter(Boolean);
}, [query]);
return {
data: results,
isLoading: false,
};
}
-47
View File
@@ -1,47 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ShareEntry } from '../types';
function shareEndpoint(resourceId: string) {
return `/resources/${resourceId}/share`;
}
export function useShares(resourceId: string) {
return useQuery({
queryKey: ['shares', resourceId],
queryFn: () => apiClient.get<ShareEntry[]>(shareEndpoint(resourceId)),
enabled: !!resourceId,
});
}
export function useGrantShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ resourceId, subjectUserId, role }: { resourceId: string; subjectUserId: string; role: string }) =>
apiClient.post(shareEndpoint(resourceId), { subject_user_id: subjectUserId, role }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] });
},
});
}
export function useRevokeShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ resourceId, userId }: { resourceId: string; userId: string }) =>
apiClient.delete(`${shareEndpoint(resourceId)}/${userId}`),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] });
},
});
}
export function useCheckAccess(resourceId: string) {
return useQuery({
queryKey: ['access', resourceId],
queryFn: () => apiClient.get<{ role: string; access: boolean }>(`/resources/${resourceId}/access`),
enabled: !!resourceId,
});
}
-23
View File
@@ -1,23 +0,0 @@
import { useCallback, useRef } from 'react';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { SyncQueueItem } from '../types';
export function useSyncPull() {
const isRunning = useRef(false);
const pull = useCallback(async (locationId?: string) => {
if (isRunning.current) return { items: [] };
isRunning.current = true;
try {
const body = locationId ? { location_id: locationId } : {};
const result = await apiClient.post<SyncQueueItem[]>(ENDPOINTS.SYNC_PULL, body);
return { items: result };
} finally {
isRunning.current = false;
}
}, []);
return { pull };
}
-23
View File
@@ -1,23 +0,0 @@
import { useCallback, useRef } from 'react';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
export function useSyncPush() {
const isRunning = useRef(false);
const push = useCallback(async (locationId: string) => {
if (isRunning.current) return { pending: 0 };
isRunning.current = true;
try {
const result = await apiClient.post<{ pending: number; message: string }>(ENDPOINTS.SYNC_PUSH, {
location_id: locationId,
});
return { pending: result.pending };
} finally {
isRunning.current = false;
}
}, []);
return { push };
}
-133
View File
@@ -1,133 +0,0 @@
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
import { createMMKV } from 'react-native-mmkv';
import { fileStore } from '../services/fileStore';
export type SyncProgress = {
files: Array<{ id: string; name: string }>;
currentIndex: number;
} | null;
const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' });
const PROGRESS_KEY = 'sync_progress';
let syncRunning = false;
let cancelRequested = false;
const listeners = new Set<() => void>();
function notify() {
listeners.forEach((l) => l());
}
export function getIsSyncing(): boolean {
return syncStateStorage.getString('is_syncing') === 'true';
}
export function setIsSyncing(value: boolean) {
syncStateStorage.set('is_syncing', value ? 'true' : 'false');
notify();
}
export function getSyncProgress(): SyncProgress {
return parseProgress(syncStateStorage.getString(PROGRESS_KEY));
}
function parseProgress(raw: string | undefined): SyncProgress {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { files: Array<{ id: string; name: string }>; currentIndex: number };
if (!parsed || !Array.isArray(parsed.files)) return null;
return parsed;
} catch {
return null;
}
}
export function setSyncProgress(value: SyncProgress) {
if (value === null) {
syncStateStorage.remove(PROGRESS_KEY);
} else {
syncStateStorage.set(PROGRESS_KEY, JSON.stringify(value));
}
notify();
}
export function isSyncLoopRunning(): boolean {
return syncRunning;
}
export function setSyncLoopRunning(value: boolean) {
syncRunning = value;
}
export function requestSyncCancel() {
cancelRequested = true;
}
export function isSyncCancelRequested(): boolean {
return cancelRequested;
}
export function consumeSyncCancel(): boolean {
const value = cancelRequested;
cancelRequested = false;
return value;
}
export function resetSyncState() {
syncStateStorage.remove('is_syncing');
syncStateStorage.remove(PROGRESS_KEY);
syncRunning = false;
cancelRequested = false;
notify();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
let cachedIsSyncing = getIsSyncing();
let lastIsSyncingRaw: string | undefined;
function getIsSyncingSnapshot(): boolean {
const raw = syncStateStorage.getString('is_syncing');
if (raw !== lastIsSyncingRaw) {
lastIsSyncingRaw = raw;
cachedIsSyncing = raw === 'true';
}
return cachedIsSyncing;
}
let cachedProgress = getSyncProgress();
let lastProgressRaw: string | undefined;
function getSyncProgressSnapshot(): SyncProgress {
const raw = syncStateStorage.getString(PROGRESS_KEY);
if (raw !== lastProgressRaw) {
lastProgressRaw = raw;
cachedProgress = parseProgress(raw);
}
return cachedProgress;
}
export function useSyncQueue() {
const isSyncing = useSyncExternalStore(subscribe, getIsSyncingSnapshot, getIsSyncingSnapshot);
const [pendingCount, setPendingCount] = useState(0);
const refresh = useCallback(() => {
setPendingCount(fileStore.countPendingSync());
}, []);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 5000);
return () => clearInterval(interval);
}, [refresh]);
return { pendingCount, isSyncing, refresh };
}
export function useSyncProgress() {
const syncProgress = useSyncExternalStore(subscribe, getSyncProgressSnapshot, getSyncProgressSnapshot);
return { syncProgress };
}
-79
View File
@@ -1,79 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { File, UploadType } from 'expo-file-system';
import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, UploadError } from '../types';
export type UploadFile = { uri: string; type: string; name: string };
export type UploadResult = { name: string; id: string };
export function useUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (files: UploadFile[]) => {
const results = await Promise.allSettled(
files.map(async (file) => {
const fsFile = new File(file.uri);
const headers: Record<string, string> = {};
const token = apiClient.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
httpMethod: 'POST',
uploadType: UploadType.MULTIPART,
fieldName: 'file',
mimeType: file.type,
headers,
});
if (result.status >= 400) {
let serverMessage = 'Erreur serveur';
let serverCode: string | undefined;
try {
const body: ApiError = JSON.parse(result.body);
serverMessage = body.error?.message || serverMessage;
serverCode = body.error?.code;
} catch {
serverMessage = result.body || serverMessage;
}
throw new UploadError(file.name, result.status, serverMessage, serverCode);
}
const body = JSON.parse(result.body);
const items = body.data ?? body;
const item = Array.isArray(items) ? items[0] : items;
return item as UploadResult;
})
);
const uploaded: UploadResult[] = [];
const errors: UploadError[] = [];
results.forEach((r, i) => {
if (r.status === 'fulfilled') {
uploaded.push(r.value);
} else {
const reason = r.reason;
if (reason instanceof UploadError) {
errors.push(reason);
} else {
errors.push(new UploadError(files[i].name, 0, reason?.message || 'Upload failed'));
}
}
});
if (errors.length > 0 && uploaded.length === 0) {
throw errors[0];
}
return { uploaded, errors };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
-17
View File
@@ -1,17 +0,0 @@
import { useSyncExternalStore } from 'react';
import { uploadQueue, UploadFile } from '../services/uploadQueue';
export function useUploadQueue() {
const tasks = useSyncExternalStore(
uploadQueue.subscribe.bind(uploadQueue),
uploadQueue.getTasks.bind(uploadQueue),
);
return {
tasks,
enqueue: (files: UploadFile[]) => uploadQueue.enqueue(files),
cancel: (id: string) => uploadQueue.cancel(id),
retry: (id: string) => uploadQueue.retry(id),
retryAll: () => uploadQueue.retryAll(),
};
}
@@ -1,15 +0,0 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'host.exp.exponent'
version = '0.1.0'
android {
namespace "expo.modules.downloaddetect"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
}
@@ -1,3 +0,0 @@
<manifest>
</manifest>
@@ -1,77 +0,0 @@
package expo.modules.downloaddetect
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
data class DetectedFile(
val id: String,
val uri: String,
val name: String,
val mimeType: String,
val size: Long,
val createdAt: Long
)
class DownloadBroadcastReceiver(
private val onFileDetected: (DetectedFile) -> Unit
) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (downloadId == -1L) return
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
dm.query(query)?.use { cursor ->
if (!cursor.moveToFirst()) return
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val titleIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)
val mimeTypeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)
val sizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val dateIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)
val uri = if (uriIndex >= 0) cursor.getString(uriIndex) ?: "" else ""
val title = if (titleIndex >= 0) cursor.getString(titleIndex) ?: "unknown" else "unknown"
val mimeType = if (mimeTypeIndex >= 0) cursor.getString(mimeTypeIndex) ?: "application/octet-stream" else "application/octet-stream"
val size = if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L
val date = if (dateIndex >= 0) cursor.getLong(dateIndex) else System.currentTimeMillis()
// Extract filename from URI
val name = extractFileName(uri, title)
onFileDetected(DetectedFile(
id = "download_$downloadId",
uri = uri,
name = name,
mimeType = mimeType,
size = size,
createdAt = date
))
}
}
private fun extractFileName(uri: String, fallback: String): String {
if (uri.isNotEmpty()) {
// content://media/external/downloads/123 or file:///storage/...
val path = uri.substringAfterLast("/")
if (path.isNotEmpty() && path.all { it.isDigit() }.not()) {
return decodeFileName(path)
}
}
return fallback
}
private fun decodeFileName(encoded: String): String {
return try {
java.net.URLDecoder.decode(encoded, "UTF-8")
} catch (_: Exception) {
encoded
}
}
}
@@ -1,169 +0,0 @@
package expo.modules.downloaddetect
import android.app.DownloadManager
import android.content.Context
import android.content.IntentFilter
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoDownloadDetectModule : Module() {
private var broadcastReceiver: DownloadBroadcastReceiver? = null
private var contentObserver: MediaStoreObserver? = null
private var isWatching = false
override fun definition() = ModuleDefinition {
Name("ExpoDownloadDetect")
Events("onNewFile")
Function("startWatching") {
if (!isWatching) startObserving()
null
}
Function("stopWatching") {
stopObserving()
}
AsyncFunction("getRecentDownloads") {
queryRecentFiles()
}
}
private val context: Context
get() = requireNotNull(appContext.reactContext)
private fun startObserving() {
val ctx = context
// Register BroadcastReceiver for DownloadManager
broadcastReceiver = DownloadBroadcastReceiver { event ->
sendEvent("onNewFile", bundleOf(
"id" to event.id,
"uri" to event.uri,
"name" to event.name,
"mimeType" to event.mimeType,
"size" to event.size,
"createdAt" to event.createdAt,
"source" to "download"
))
}
val intentFilter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ctx.registerReceiver(broadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
// Register ContentObserver on MediaStore
val handler = Handler(Looper.getMainLooper())
contentObserver = MediaStoreObserver(handler, ctx) { newFiles ->
for (file in newFiles) {
sendEvent("onNewFile", bundleOf(
"id" to file.id,
"uri" to file.uri,
"name" to file.name,
"mimeType" to file.mimeType,
"size" to file.size,
"createdAt" to file.createdAt,
"source" to "mediastore"
))
}
}
val uri = MediaStore.Files.getContentUri("external")
ctx.contentResolver.registerContentObserver(uri, true, contentObserver!!)
// Scan existing files on startup
val existingFiles = queryRecentFiles()
for (file in existingFiles) {
sendEvent("onNewFile", bundleOf(
"id" to file.id,
"uri" to file.uri,
"name" to file.name,
"mimeType" to file.mimeType,
"size" to file.size,
"createdAt" to file.createdAt,
"source" to "startup"
))
}
isWatching = true
}
private fun stopObserving() {
val ctx = context
broadcastReceiver?.let {
try {
ctx.unregisterReceiver(it)
} catch (_: Exception) {}
broadcastReceiver = null
}
contentObserver?.let {
ctx.contentResolver.unregisterContentObserver(it)
contentObserver = null
}
isWatching = false
}
private fun queryRecentFiles(): List<DetectedFile> {
val files = mutableListOf<DetectedFile>()
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns.MIME_TYPE,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATE_ADDED
)
val selection = "${MediaStore.Files.FileColumns.SIZE} > 0"
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
val limit = 100
context.contentResolver.query(
MediaStore.Files.getContentUri("external"),
projection,
selection,
null,
sortOrder
)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE)
val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
var count = 0
while (cursor.moveToNext() && count < limit) {
val id = cursor.getLong(idCol)
val name = cursor.getString(nameCol) ?: continue
val mimeType = cursor.getString(mimeCol) ?: "application/octet-stream"
val size = cursor.getLong(sizeCol)
val dateAdded = cursor.getLong(dateCol)
val uri = Uri.withAppendedPath(
MediaStore.Files.getContentUri("external"),
id.toString()
).toString()
files.add(DetectedFile(
id = "media_$id",
uri = uri,
name = name,
mimeType = mimeType,
size = size,
createdAt = dateAdded * 1000L
))
count++
}
}
return files
}
}
@@ -1,105 +0,0 @@
package expo.modules.downloaddetect
import android.content.ContentResolver
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Handler
import android.provider.MediaStore
class MediaStoreObserver(
handler: Handler,
private val context: Context,
private val onNewFiles: (List<DetectedFile>) -> Unit
) : ContentObserver(handler) {
private var lastScanTimestamp: Long = System.currentTimeMillis() / 1000L
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
queryNewFiles()
}
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
super.onChange(selfChange, uri, flags)
queryNewFiles()
}
private fun queryNewFiles() {
val files = mutableListOf<DetectedFile>()
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns.MIME_TYPE,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATE_ADDED
)
val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND ${MediaStore.Files.FileColumns.SIZE} > 0"
val selectionArgs = arrayOf(lastScanTimestamp.toString())
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
val resolver: ContentResolver = context.contentResolver
var cursor: Cursor? = null
try {
// Try MediaStore.Downloads first
cursor = try {
resolver.query(
MediaStore.Downloads.getContentUri("external"),
projection, selection, selectionArgs, sortOrder
)
} catch (_: Exception) {
null
}
// Fallback to MediaStore.Files if Downloads didn't work
if (cursor == null || cursor.count == 0) {
cursor?.close()
cursor = resolver.query(
MediaStore.Files.getContentUri("external"),
projection, selection, selectionArgs, sortOrder
)
}
cursor?.use { c ->
val idCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val nameCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
val mimeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)
val sizeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE)
val dateCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
while (c.moveToNext()) {
val id = c.getLong(idCol)
val name = c.getString(nameCol) ?: continue
val mimeType = c.getString(mimeCol) ?: "application/octet-stream"
val size = c.getLong(sizeCol)
val dateAdded = c.getLong(dateCol)
val fileUri = Uri.withAppendedPath(
MediaStore.Files.getContentUri("external"),
id.toString()
).toString()
files.add(DetectedFile(
id = "media_$id",
uri = fileUri,
name = name,
mimeType = mimeType,
size = size,
createdAt = dateAdded * 1000L
))
}
}
} catch (_: Exception) {
} finally {
cursor?.close()
}
if (files.isNotEmpty()) {
lastScanTimestamp = System.currentTimeMillis() / 1000L
onNewFiles(files)
}
}
}
@@ -1,6 +0,0 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.downloaddetect.ExpoDownloadDetectModule"]
}
}
@@ -1,2 +0,0 @@
export { ExpoDownloadDetectModule } from './src';
export type { FileDetectedEvent } from './src';
@@ -1,12 +0,0 @@
{
"name": "expo-download-detect",
"version": "0.1.0",
"description": "Detect new files via DownloadManager and MediaStore ContentObserver",
"main": "index.ts",
"android": {
"sourceDir": "android"
},
"peerDependencies": {
"expo": ">=57.0.0"
}
}
@@ -1,13 +0,0 @@
export type FileDetectedEvent = {
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: number;
source: 'download' | 'mediastore' | 'startup';
};
export type ExpoDownloadDetectModuleEvents = {
onNewFile: (event: FileDetectedEvent) => void;
};
@@ -1,10 +0,0 @@
import { NativeModule, requireNativeModule } from 'expo';
import { ExpoDownloadDetectModuleEvents, FileDetectedEvent } from './ExpoDownloadDetect.types';
declare class ExpoDownloadDetectModule extends NativeModule<ExpoDownloadDetectModuleEvents> {
startWatching(): void;
stopWatching(): void;
getRecentDownloads(): Promise<FileDetectedEvent[]>;
}
export default requireNativeModule<ExpoDownloadDetectModule>('ExpoDownloadDetect');
@@ -1,2 +0,0 @@
export { default as ExpoDownloadDetectModule } from './ExpoDownloadDetectModule';
export type { FileDetectedEvent, ExpoDownloadDetectModuleEvents } from './ExpoDownloadDetect.types';
+695 -3022
View File
File diff suppressed because it is too large Load Diff
+4 -34
View File
@@ -1,46 +1,16 @@
{ {
"name": "webui", "name": "webui",
"version": "1.0.0", "version": "2.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2",
"@react-native-community/netinfo": "12.0.1",
"@react-navigation/native": "^7.3.8",
"@react-navigation/native-stack": "^7.17.10",
"@tanstack/query-async-storage-persister": "^5.101.4",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.4",
"babel-preset-expo": "^57.0.4",
"drizzle-orm": "^0.45.2",
"expo": "~57.0.8", "expo": "~57.0.8",
"expo-background-task": "~57.0.6",
"expo-blur": "~57.0.2",
"expo-document-picker": "~57.0.1",
"expo-file-system": "~57.0.1",
"expo-haptics": "~57.0.2",
"expo-image": "~57.0.1",
"expo-image-manipulator": "~57.0.14",
"expo-print": "~57.0.1",
"expo-secure-store": "~57.0.1",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-task-manager": "~57.0.6",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0"
"react-native-gesture-handler": "~2.32.0",
"react-native-mmkv": "^4.3.2",
"react-native-nitro-image": "^0.15.1",
"react-native-nitro-modules": "^0.36.1",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-vision-camera": "^5.1.0",
"react-native-worklets": "0.10.0"
}, },
"devDependencies": { "devDependencies": {
"@expo/metro-config": "^57.0.7",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"drizzle-kit": "^0.31.10", "babel-preset-expo": "^57.0.4",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"scripts": { "scripts": {
@@ -50,4 +20,4 @@
"web": "expo start --web" "web": "expo start --web"
}, },
"private": true "private": true
} }
-168
View File
@@ -1,168 +0,0 @@
import { fileStore } from './fileStore';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { HttpError } from '../types';
import type { PendingAction, PendingActionType } from '../types';
type Listener = () => void;
const MAX_ATTEMPTS = 5;
const BASE_RETRY_DELAY_MS = 2000;
function genId(): string {
return `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
async function executeAction(action: PendingAction): Promise<void> {
const { type, payload, resourceId } = action;
switch (type) {
case 'tag_add': {
const { fileId, tags } = payload as { fileId: string; tags: string[] };
await apiClient.post(`${ENDPOINTS.RESOURCES}/${fileId}/tags`, { tags });
break;
}
case 'delete': {
const { backendId } = payload as { backendId: string };
try {
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${backendId}`);
} catch (err) {
if (err instanceof HttpError && err.status === 404) {
break;
}
throw err;
}
break;
}
case 'move': {
const { resourceIds, parentResourceId } = payload as { resourceIds: string[]; parentResourceId: string | null };
await apiClient.post(ENDPOINTS.MOVE, { resource_ids: resourceIds, parent_resource_id: parentResourceId });
break;
}
case 'create_folder': {
const { name, parentResourceId } = payload as { name: string; parentResourceId?: string };
await apiClient.post(ENDPOINTS.FOLDERS, { name, parent_resource_id: parentResourceId });
break;
}
default:
throw new Error(`Unknown action type: ${type}`);
}
}
class ActionQueue {
private processing = false;
private listeners = new Set<Listener>();
private timer: ReturnType<typeof setTimeout> | null = null;
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
private notify() {
this.listeners.forEach((l) => l());
}
enqueue(type: PendingActionType, payload: Record<string, unknown>, resourceId?: string): PendingAction {
const action: PendingAction = {
id: genId(),
type,
payload,
status: 'pending',
attempts: 0,
lastError: null,
resourceId: resourceId ?? null,
createdAt: new Date().toISOString(),
};
fileStore.insertPendingAction(action);
this.notify();
return action;
}
async processNext(): Promise<boolean> {
const pending = fileStore.getPendingActions();
if (pending.length === 0) return false;
const action = pending[0];
// Check if a prior delete makes this action obsolete
if (action.type !== 'delete' && action.resourceId) {
const priorDelete = fileStore.getPendingActions().find(
(a) => a.type === 'delete' && a.resourceId === action.resourceId && a.createdAt < action.createdAt
);
if (priorDelete) {
fileStore.markPendingActionObsolete(action.id);
this.notify();
return true;
}
}
try {
await executeAction(action);
fileStore.markPendingActionDone(action.id);
this.notify();
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
fileStore.markPendingActionError(action.id, msg);
if (action.attempts + 1 >= MAX_ATTEMPTS) {
fileStore.markPendingActionObsolete(action.id);
}
this.notify();
return false;
}
}
async processAll(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
let hasMore = true;
while (hasMore) {
const pending = fileStore.getPendingActions();
if (pending.length === 0) break;
const success = await this.processNext();
if (!success) {
// Wait before retrying on error
await new Promise((r) => setTimeout(r, BASE_RETRY_DELAY_MS));
// Check if there are still pending actions (not just the one we failed on)
const remaining = fileStore.getPendingActions();
if (remaining.length === 0 || remaining[0].status !== 'pending') break;
}
}
} finally {
this.processing = false;
fileStore.clearDonePendingActions();
this.notify();
}
}
isProcessing(): boolean {
return this.processing;
}
getPendingCount(): number {
return fileStore.getPendingActionsCount();
}
scheduleRetry(delayMs: number = 10_000) {
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = null;
this.processAll();
}, delayMs);
}
cancelSchedule() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
export const actionQueue = new ActionQueue();
-69
View File
@@ -1,69 +0,0 @@
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
const BACKGROUND_UPLOAD_TASK = 'BACKGROUND_UPLOAD';
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
try {
const { uploadQueue } = await import('./uploadQueue');
const { actionQueue } = await import('./actionQueue');
const { apiClient } = await import('../api/client');
const { tokenStorage } = await import('../api/secureStorage');
const pendingUploads = uploadQueue.getPendingCount();
const pendingActions = actionQueue.getPendingCount();
if (pendingUploads === 0 && pendingActions === 0) {
return BackgroundTask.BackgroundTaskResult.Success;
}
const token = await tokenStorage.getAccessToken();
if (!token) {
return BackgroundTask.BackgroundTaskResult.Success;
}
apiClient.setAccessToken(token);
if (pendingUploads > 0) {
uploadQueue.retryAll();
}
if (pendingActions > 0) {
actionQueue.processAll();
}
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (uploadQueue.getPendingCount() === 0 && actionQueue.getPendingCount() === 0) {
clearInterval(check);
resolve();
}
}, 1000);
setTimeout(() => {
clearInterval(check);
resolve();
}, 25000);
});
return BackgroundTask.BackgroundTaskResult.Success;
} catch {
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
let isRegistered = false;
export async function registerBackgroundUpload() {
if (isRegistered) return;
isRegistered = true;
const status = await BackgroundTask.getStatusAsync();
if (status === BackgroundTask.BackgroundTaskStatus.Restricted) {
console.warn('[BackgroundUpload] Permission refusée');
return;
}
await BackgroundTask.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
minimumInterval: 15,
});
console.log('[BackgroundUpload] Enregistré (intervalle: 15min)');
}
-98
View File
@@ -1,98 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
import { FileDetectedEvent } from '../modules/expo-download-detect';
const storage = createMMKV({ id: 'vaultdrop-download-registry' });
const FILES_KEY = 'detected_files';
export type DownloadRegistryEntry = {
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: number;
source: string;
detectedAt: number;
};
function getAllRaw(): DownloadRegistryEntry[] {
const raw = storage.getString(FILES_KEY);
if (!raw) return [];
return JSON.parse(raw) as DownloadRegistryEntry[];
}
function saveAll(entries: DownloadRegistryEntry[]) {
storage.set(FILES_KEY, JSON.stringify(entries));
}
export const downloadRegistry = {
getAll(): DownloadRegistryEntry[] {
return getAllRaw();
},
getById(id: string): DownloadRegistryEntry | undefined {
return getAllRaw().find((e) => e.id === id);
},
add(event: FileDetectedEvent): DownloadRegistryEntry | null {
const entries = getAllRaw();
if (entries.some((e) => e.id === event.id)) return null;
const entry: DownloadRegistryEntry = {
id: event.id,
uri: event.uri,
name: event.name,
mimeType: event.mimeType,
size: event.size,
createdAt: event.createdAt,
source: event.source,
detectedAt: Date.now(),
};
entries.push(entry);
saveAll(entries);
return entry;
},
addBatch(events: FileDetectedEvent[]): DownloadRegistryEntry[] {
const entries = getAllRaw();
const existingIds = new Set(entries.map((e) => e.id));
const newEntries: DownloadRegistryEntry[] = [];
for (const event of events) {
if (existingIds.has(event.id)) continue;
const entry: DownloadRegistryEntry = {
id: event.id,
uri: event.uri,
name: event.name,
mimeType: event.mimeType,
size: event.size,
createdAt: event.createdAt,
source: event.source,
detectedAt: Date.now(),
};
newEntries.push(entry);
existingIds.add(event.id);
}
if (newEntries.length > 0) {
saveAll([...entries, ...newEntries]);
}
return newEntries;
},
remove(id: string) {
const entries = getAllRaw().filter((e) => e.id !== id);
saveAll(entries);
},
clear() {
storage.remove(FILES_KEY);
},
count(): number {
return getAllRaw().length;
},
};
-757
View File
@@ -1,757 +0,0 @@
import { drizzle } from 'drizzle-orm/expo-sqlite';
import * as SQLite from 'expo-sqlite';
import { eq, like, or, and, desc, asc, sql, isNull, inArray, isNotNull } from 'drizzle-orm';
import { files, fileTags, deletedFiles, pendingActions } from './schema';
import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from '../../types';
const DB_NAME = 'vaultdrop-v3.db';
const SCHEMA_VERSION_KEY = 'schema_version';
const SCHEMA_VERSION = 5;
let _db: ReturnType<typeof drizzle> | null = null;
let _sqliteDb: SQLite.SQLiteDatabase | null = null;
export function initDB() {
if (_db) return _db;
_sqliteDb = SQLite.openDatabaseSync(DB_NAME);
_sqliteDb.execSync('PRAGMA journal_mode = WAL;');
_sqliteDb.execSync('PRAGMA foreign_keys = ON;');
const existingVersion = _sqliteDb.getFirstSync<{ version: number }>(
`SELECT name as version FROM sqlite_master WHERE type='table' AND name='schema_version'`
);
if (!existingVersion) {
createSchema(_sqliteDb);
_sqliteDb.execSync(`CREATE TABLE schema_version (version INTEGER PRIMARY KEY);`);
_sqliteDb.execSync(`INSERT INTO schema_version (version) VALUES (${SCHEMA_VERSION});`);
} else {
const versionRow = _sqliteDb.getFirstSync<{ version: number }>(
`SELECT version FROM schema_version ORDER BY version DESC LIMIT 1`
);
const currentVersion = versionRow?.version ?? 0;
if (currentVersion < SCHEMA_VERSION) {
migrate(_sqliteDb, currentVersion, SCHEMA_VERSION);
_sqliteDb.execSync(`UPDATE schema_version SET version = ${SCHEMA_VERSION};`);
}
}
_db = drizzle(_sqliteDb);
return _db;
}
const MIGRATIONS: Array<(db: SQLite.SQLiteDatabase) => void> = [
// v5: add thumbnail_local column
(db) => {
db.execSync(`ALTER TABLE files ADD COLUMN thumbnail_local TEXT;`);
},
];
function migrate(db: SQLite.SQLiteDatabase, fromVersion: number, toVersion: number) {
for (let v = fromVersion; v < toVersion; v++) {
const migration = MIGRATIONS[v - 1];
if (migration) migration(db);
}
}
function createSchema(db: SQLite.SQLiteDatabase) {
db.execSync(`
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
backend_id TEXT,
name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'cloud',
local_uri TEXT,
sync_status TEXT NOT NULL DEFAULT 'cloud',
parent_resource_id TEXT,
is_folder INTEGER NOT NULL DEFAULT 0,
ocr_text TEXT,
thumbnail_url TEXT,
thumbnail_local TEXT,
owner_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_synced_at TEXT
);
`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_backend_id ON files(backend_id);`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_parent_resource_id ON files(parent_resource_id);`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_source ON files(source);`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_is_folder ON files(is_folder);`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_sync_status ON files(sync_status);`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_owner_id ON files(owner_id);`);
db.execSync(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);`);
db.execSync(`
CREATE TABLE IF NOT EXISTS file_tags (
id TEXT PRIMARY KEY,
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
tag_name TEXT NOT NULL
);
`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_file_tags_file_id ON file_tags(file_id);`);
db.execSync(`
CREATE TABLE IF NOT EXISTS deleted_files (
id TEXT PRIMARY KEY,
deleted_at TEXT NOT NULL
);
`);
db.execSync(`
CREATE TABLE IF NOT EXISTS device_info (
id TEXT PRIMARY KEY,
server_id TEXT,
device_name TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
registered_at TEXT
);
`);
db.execSync(`
CREATE TABLE IF NOT EXISTS pending_actions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
resource_id TEXT,
created_at TEXT NOT NULL
);
`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_pending_actions_status ON pending_actions(status);`);
db.execSync(`CREATE INDEX IF NOT EXISTS idx_pending_actions_type ON pending_actions(type);`);
db.execSync(`
CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
name,
ocr_text,
content='files',
content_rowid='rowid'
);
`);
db.execSync(`
CREATE TRIGGER IF NOT EXISTS resources_fts_insert AFTER INSERT ON files BEGIN
INSERT INTO resources_fts(rowid, name, ocr_text) VALUES (new.rowid, new.name, new.ocr_text);
END;
`);
db.execSync(`
CREATE TRIGGER IF NOT EXISTS resources_fts_delete AFTER DELETE ON files BEGIN
INSERT INTO resources_fts(resources_fts, rowid, name, ocr_text) VALUES('delete', old.rowid, old.name, old.ocr_text);
END;
`);
db.execSync(`
CREATE TRIGGER IF NOT EXISTS resources_fts_update AFTER UPDATE ON files BEGIN
INSERT INTO resources_fts(resources_fts, rowid, name, ocr_text) VALUES('delete', old.rowid, old.name, old.ocr_text);
INSERT INTO resources_fts(rowid, name, ocr_text) VALUES (new.rowid, new.name, new.ocr_text);
END;
`);
}
function getDb() {
if (!_db) initDB();
return _db!;
}
export type FileRecord = {
id: string;
backendId: string | null;
name: string;
mimeType: string;
size: number;
source: string;
localUri: string | null;
syncStatus: string;
parentResourceId: string | null;
isFolder: number;
ocrText: string | null;
thumbnailUrl: string | null;
thumbnailLocal?: string | null;
ownerId: string | null;
createdAt: string;
updatedAt: string;
lastSyncedAt: string | null;
tags?: Tag[];
};
type FileRow = {
id: string;
backendId: string | null;
name: string;
mimeType: string;
size: number;
source: string;
localUri: string | null;
syncStatus: string;
parentResourceId: string | null;
isFolder: number;
ocrText: string | null;
thumbnailUrl: string | null;
thumbnailLocal: string | null;
ownerId: string | null;
createdAt: string;
updatedAt: string;
lastSyncedAt: string | null;
};
function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord {
return {
id: row.id,
backendId: row.backendId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
source: row.source,
localUri: row.localUri,
syncStatus: row.syncStatus,
parentResourceId: row.parentResourceId,
isFolder: row.isFolder,
ocrText: row.ocrText,
thumbnailUrl: row.thumbnailUrl,
thumbnailLocal: row.thumbnailLocal,
ownerId: row.ownerId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastSyncedAt: row.lastSyncedAt,
tags,
};
}
function getTagsForFile(fileId: string): Tag[] {
const d = getDb();
const rows = d.select().from(fileTags).where(eq(fileTags.fileId, fileId)).all();
return rows.map((r) => ({ id: r.tagName, tag_name: r.tagName }));
}
function setTagsForFile(fileId: string, tags: Tag[]) {
const d = getDb();
d.delete(fileTags).where(eq(fileTags.fileId, fileId)).run();
if (tags.length === 0) return;
d.insert(fileTags).values(
tags.map((t) => ({
id: `${fileId}_${t.id || t.tag_name}`,
fileId,
tagName: t.tag_name,
})),
).run();
}
function upsertRow(file: FileRecord) {
const d = getDb();
d.insert(files).values({
id: file.id,
backendId: file.backendId,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: file.source,
localUri: file.localUri,
syncStatus: file.syncStatus,
parentResourceId: file.parentResourceId,
isFolder: file.isFolder,
ocrText: file.ocrText,
thumbnailUrl: file.thumbnailUrl,
thumbnailLocal: file.thumbnailLocal ?? null,
ownerId: file.ownerId,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
lastSyncedAt: file.lastSyncedAt,
}).onConflictDoUpdate({
target: files.id,
set: {
backendId: file.backendId,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: file.source,
localUri: file.localUri,
syncStatus: file.syncStatus,
parentResourceId: file.parentResourceId,
isFolder: file.isFolder,
ocrText: file.ocrText,
thumbnailUrl: file.thumbnailUrl,
thumbnailLocal: file.thumbnailLocal ?? null,
ownerId: file.ownerId,
updatedAt: file.updatedAt,
lastSyncedAt: file.lastSyncedAt,
},
}).run();
}
export const fileStore = {
initDB,
upsert(file: FileRecord) {
upsertRow(file);
if (file.tags) setTagsForFile(file.id, file.tags);
},
upsertBatch(fileList: FileRecord[]) {
const d = getDb();
for (const file of fileList) {
upsertRow(file);
if (file.tags) setTagsForFile(file.id, file.tags);
}
},
getById(id: string): FileRecord | null {
const d = getDb();
const row = d.select().from(files).where(eq(files.id, id)).get() as FileRow | undefined;
if (!row) return null;
return rowToRecord(row, getTagsForFile(id));
},
getByBackendId(backendId: string): FileRecord | null {
const d = getDb();
const row = d.select().from(files).where(eq(files.backendId, backendId)).get() as FileRow | undefined;
if (!row) return null;
return rowToRecord(row, getTagsForFile(row.id));
},
getByLocalUri(localUri: string): FileRecord | null {
const d = getDb();
const row = d.select().from(files).where(eq(files.localUri, localUri)).get() as FileRow | undefined;
if (!row) return null;
return rowToRecord(row, getTagsForFile(row.id));
},
getRootFolders(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(and(eq(files.isFolder, 1), isNull(files.parentResourceId)))
.orderBy(asc(files.name))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getChildrenByParent(parentId: string): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.parentResourceId, parentId))
.orderBy(desc(files.isFolder), desc(files.createdAt))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getRootFiles(): { files: FileRecord[]; total: number } {
const d = getDb();
const countRow = d.select({ count: sql<number>`count(*)` })
.from(files)
.where(isNull(files.parentResourceId))
.get();
const total = countRow?.count ?? 0;
const rows = d.select().from(files)
.where(isNull(files.parentResourceId))
.orderBy(desc(files.isFolder), desc(files.createdAt))
.all() as FileRow[];
return {
files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))),
total,
};
},
getPaginated(page: number, limit: number): { files: FileRecord[]; total: number } {
const d = getDb();
const offset = (page - 1) * limit;
const countRow = d.select({ count: sql<number>`count(*)` })
.from(files)
.where(isNull(files.parentResourceId))
.get();
const total = countRow?.count ?? 0;
const rows = d.select().from(files)
.where(isNull(files.parentResourceId))
.orderBy(desc(files.isFolder), desc(files.createdAt))
.limit(limit)
.offset(offset)
.all() as FileRow[];
return {
files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))),
total,
};
},
getAllFolders(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.isFolder, 1))
.orderBy(asc(files.name))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
searchFts(query: string): FileRecord[] {
const d = getDb();
const sanitized = query.replace(/['"]/g, '').trim();
if (!sanitized) return [];
const ftsPattern = sanitized.split(/\s+/).map(w => `"${w}"`).join(' OR ');
const sqlQuery = `
SELECT f.* FROM files f
JOIN resources_fts r ON r.rowid = f.rowid
WHERE resources_fts MATCH ?
ORDER BY rank
LIMIT 100
`;
const sqliteDb = _sqliteDb!;
const stmt = sqliteDb.prepareSync(sqlQuery);
const result = stmt.executeSync<FileRow>(ftsPattern);
const rows: FileRow[] = [];
for (const r of result) {
rows.push(r as unknown as FileRow);
}
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
search(query: string): FileRecord[] {
const d = getDb();
const pattern = `%${query}%`;
const rows = d.select().from(files)
.where(or(like(files.name, pattern), like(files.ocrText, pattern)))
.orderBy(desc(files.createdAt))
.limit(100)
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
mergeFromBackend(backendFiles: Array<{
id: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
updatedAt?: string;
ocrText?: string;
tags?: Tag[];
isFolder: boolean;
parentResourceId?: string;
thumbnailUrl?: string;
ownerId?: string;
}>) {
const d = getDb();
const now = new Date().toISOString();
d.transaction(() => {
for (const bf of backendFiles) {
const existing = d.select().from(files).where(eq(files.backendId, bf.id)).get() as FileRow | undefined;
const recordId = existing?.id ?? bf.id;
const source = existing && existing.localUri ? 'synced' : 'cloud';
const syncStatus = existing && existing.localUri
? (existing.syncStatus === 'cloud' ? 'synced' : existing.syncStatus)
: 'cloud';
upsertRow({
id: recordId,
backendId: bf.id,
name: bf.name,
mimeType: bf.mimeType,
size: bf.size,
source,
localUri: existing?.localUri ?? null,
syncStatus,
parentResourceId: bf.parentResourceId ?? null,
isFolder: bf.isFolder ? 1 : 0,
ocrText: bf.ocrText ?? null,
thumbnailUrl: bf.thumbnailUrl ?? null,
thumbnailLocal: existing?.thumbnailLocal ?? null,
ownerId: bf.ownerId ?? null,
createdAt: bf.createdAt,
updatedAt: bf.updatedAt ?? now,
lastSyncedAt: now,
});
if (bf.tags && bf.tags.length > 0) setTagsForFile(recordId, bf.tags);
}
});
},
mergeFromDevice(deviceFiles: Array<{
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
folderId?: string;
}>) {
const d = getDb();
const now = new Date().toISOString();
d.transaction(() => {
for (const df of deviceFiles) {
if (this.isDeleted(df.id)) continue;
const existing = d.select().from(files).where(eq(files.id, df.id)).get();
if (existing) continue;
upsertRow({
id: df.id,
backendId: null,
name: df.name,
mimeType: df.mimeType,
size: df.size,
source: 'local',
localUri: df.uri,
syncStatus: 'local',
parentResourceId: df.folderId ?? null,
isFolder: 0,
ocrText: null,
thumbnailUrl: null,
thumbnailLocal: null,
ownerId: null,
createdAt: df.createdAt,
updatedAt: now,
lastSyncedAt: null,
});
}
});
},
updatePartial(id: string, updates: Partial<FileRecord>) {
const d = getDb();
const setFields: Record<string, unknown> = {};
if (updates.backendId !== undefined) setFields.backendId = updates.backendId;
if (updates.syncStatus !== undefined) setFields.syncStatus = updates.syncStatus;
if (updates.localUri !== undefined) setFields.localUri = updates.localUri;
if (updates.source !== undefined) setFields.source = updates.source;
if (updates.thumbnailUrl !== undefined) setFields.thumbnailUrl = updates.thumbnailUrl;
if (updates.thumbnailLocal !== undefined) setFields.thumbnailLocal = updates.thumbnailLocal;
if (updates.ocrText !== undefined) setFields.ocrText = updates.ocrText;
if (updates.parentResourceId !== undefined) setFields.parentResourceId = updates.parentResourceId;
if (updates.name !== undefined) setFields.name = updates.name;
if (updates.ownerId !== undefined) setFields.ownerId = updates.ownerId;
setFields.updatedAt = new Date().toISOString();
d.update(files).set(setFields).where(eq(files.id, id)).run();
},
updateSyncStatus(id: string, syncStatus: string) {
this.updatePartial(id, { syncStatus });
},
markAsCloudOnly(id: string) {
this.updatePartial(id, { syncStatus: 'cloud', localUri: null, source: 'cloud' });
},
setThumbnailUrl(backendId: string, thumbnailUrl: string) {
const d = getDb();
d.update(files).set({ thumbnailUrl, updatedAt: new Date().toISOString() })
.where(eq(files.backendId, backendId)).run();
},
setThumbnailLocal(id: string, thumbnailLocal: string) {
const d = getDb();
d.update(files).set({ thumbnailLocal, updatedAt: new Date().toISOString() })
.where(eq(files.id, id)).run();
},
markDeleted(id: string) {
const d = getDb();
d.insert(deletedFiles).values({ id, deletedAt: new Date().toISOString() })
.onConflictDoUpdate({ target: deletedFiles.id, set: { deletedAt: new Date().toISOString() } })
.run();
},
isDeleted(id: string): boolean {
const d = getDb();
const row = d.select().from(deletedFiles).where(eq(deletedFiles.id, id)).get();
return !!row;
},
deleteById(id: string) {
const d = getDb();
this.markDeleted(id);
d.delete(files).where(eq(files.id, id)).run();
},
deleteByBackendId(backendId: string) {
const d = getDb();
const row = d.select().from(files).where(eq(files.backendId, backendId)).get() as FileRow | undefined;
if (row) this.markDeleted(row.id);
d.delete(files).where(eq(files.backendId, backendId)).run();
},
clear() {
const d = getDb();
d.delete(fileTags).run();
d.delete(files).run();
},
count(): number {
const d = getDb();
const row = d.select({ count: sql<number>`count(*)` }).from(files).get();
return row?.count ?? 0;
},
countPendingSync(): number {
const d = getDb();
const row = d.select({ count: sql<number>`count(*)` }).from(files).where(
and(
or(eq(files.source, 'local'), eq(files.source, 'synced')),
isNull(files.backendId),
isNotNull(files.localUri),
inArray(files.syncStatus, ['local', 'error']),
)
).get();
return row?.count ?? 0;
},
getAllLocal(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(or(eq(files.source, 'local'), eq(files.source, 'synced')))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getLocalDeviceFiles(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(and(eq(files.source, 'local'), isNull(files.backendId)))
.orderBy(desc(files.createdAt))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getAllSynced(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.source, 'synced'))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getPendingSync(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(and(eq(files.syncStatus, 'local'), sql`${files.backendId} IS NULL`))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getErrorFiles(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.syncStatus, 'error'))
.all() as FileRow[];
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
resetSyncError(id: string) {
this.updatePartial(id, { syncStatus: 'local' });
},
// --- Pending Actions ---
insertPendingAction(action: PendingAction) {
const d = getDb();
d.insert(pendingActions).values({
id: action.id,
type: action.type,
payload: JSON.stringify(action.payload),
status: action.status,
attempts: action.attempts,
lastError: action.lastError,
resourceId: action.resourceId,
createdAt: action.createdAt,
}).run();
},
getPendingActions(): PendingAction[] {
const d = getDb();
const rows = d.select().from(pendingActions)
.where(eq(pendingActions.status, 'pending'))
.orderBy(asc(pendingActions.createdAt))
.all();
return rows.map(rowToAction);
},
getPendingActionById(id: string): PendingAction | null {
const d = getDb();
const row = d.select().from(pendingActions)
.where(eq(pendingActions.id, id))
.get();
return row ? rowToAction(row) : null;
},
getPendingActionsCount(): number {
const d = getDb();
const row = d.select({ count: sql<number>`count(*)` })
.from(pendingActions)
.where(eq(pendingActions.status, 'pending'))
.get();
return row?.count ?? 0;
},
markPendingActionDone(id: string) {
const d = getDb();
d.update(pendingActions)
.set({ status: 'done' })
.where(eq(pendingActions.id, id))
.run();
},
markPendingActionError(id: string, error: string) {
const d = getDb();
const row = d.select().from(pendingActions)
.where(eq(pendingActions.id, id))
.get();
if (!row) return;
d.update(pendingActions)
.set({
status: 'error',
attempts: row.attempts + 1,
lastError: error,
})
.where(eq(pendingActions.id, id))
.run();
},
markPendingActionObsolete(id: string) {
const d = getDb();
d.update(pendingActions)
.set({ status: 'obsolete' })
.where(eq(pendingActions.id, id))
.run();
},
deletePendingAction(id: string) {
const d = getDb();
d.delete(pendingActions).where(eq(pendingActions.id, id)).run();
},
clearDonePendingActions() {
const d = getDb();
d.delete(pendingActions).where(eq(pendingActions.status, 'done')).run();
d.delete(pendingActions).where(eq(pendingActions.status, 'obsolete')).run();
},
};
function rowToAction(row: {
id: string;
type: string;
payload: string;
status: string;
attempts: number;
lastError: string | null;
resourceId: string | null;
createdAt: string;
}): PendingAction {
return {
id: row.id,
type: row.type as PendingActionType,
payload: JSON.parse(row.payload),
status: row.status as PendingActionStatus,
attempts: row.attempts,
lastError: row.lastError,
resourceId: row.resourceId,
createdAt: row.createdAt,
};
}
-5
View File
@@ -1,5 +0,0 @@
import { initDB } from './index';
export function migrateFromLegacy() {
initDB();
}
-75
View File
@@ -1,75 +0,0 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const files = sqliteTable(
'files',
{
id: text('id').primaryKey(),
backendId: text('backend_id'),
name: text('name').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
source: text('source').notNull().default('cloud'),
localUri: text('local_uri'),
syncStatus: text('sync_status').notNull().default('cloud'),
parentResourceId: text('parent_resource_id'),
isFolder: integer('is_folder').notNull().default(0),
ocrText: text('ocr_text'),
thumbnailUrl: text('thumbnail_url'),
thumbnailLocal: text('thumbnail_local'),
ownerId: text('owner_id'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
lastSyncedAt: text('last_synced_at'),
},
(t) => [
index('idx_files_backend_id').on(t.backendId),
index('idx_files_parent_resource_id').on(t.parentResourceId),
index('idx_files_source').on(t.source),
index('idx_files_is_folder').on(t.isFolder),
index('idx_files_sync_status').on(t.syncStatus),
index('idx_files_owner_id').on(t.ownerId),
],
);
export const fileTags = sqliteTable(
'file_tags',
{
id: text('id').primaryKey(),
fileId: text('file_id')
.notNull()
.references(() => files.id, { onDelete: 'cascade' }),
tagName: text('tag_name').notNull(),
},
(t) => [index('idx_file_tags_file_id').on(t.fileId)],
);
export const deletedFiles = sqliteTable('deleted_files', {
id: text('id').primaryKey(),
deletedAt: text('deleted_at').notNull(),
});
export const deviceInfo = sqliteTable('device_info', {
id: text('id').primaryKey(),
serverId: text('server_id'),
deviceName: text('device_name').notNull().default(''),
platform: text('platform').notNull().default(''),
registeredAt: text('registered_at'),
});
export const pendingActions = sqliteTable(
'pending_actions',
{
id: text('id').primaryKey(),
type: text('type').notNull(),
payload: text('payload').notNull(),
status: text('status').notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
lastError: text('last_error'),
resourceId: text('resource_id'),
createdAt: text('created_at').notNull(),
},
(t) => [
index('idx_pending_actions_status').on(t.status),
index('idx_pending_actions_type').on(t.type),
],
);
-20
View File
@@ -1,20 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
import { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
const storage = createMMKV({ id: 'vaultdrop-query-cache' });
export function createMMKVPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
storage.set('query-cache', JSON.stringify(client));
},
restoreClient: async () => {
const raw = storage.getString('query-cache');
if (!raw) return undefined;
return JSON.parse(raw) as PersistedClient;
},
removeClient: async () => {
storage.remove('query-cache');
},
};
}
-55
View File
@@ -1,55 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
import { safDirectory } from './safDirectory';
const storage = createMMKV({ id: 'vaultdrop-onboarding' });
const COMPLETED_VERSION_KEY = 'completed_version';
const SEEN_STEPS_KEY = 'seen_steps';
export const onboardingStorage = {
getCompletedVersion(): number | undefined {
const raw = storage.getNumber(COMPLETED_VERSION_KEY);
return raw != null ? raw : undefined;
},
setCompletedVersion(version: number) {
storage.set(COMPLETED_VERSION_KEY, version);
},
getSeenSteps(): string[] {
const raw = storage.getString(SEEN_STEPS_KEY);
if (!raw) return [];
return JSON.parse(raw) as string[];
},
markStepSeen(stepId: string) {
const seen = this.getSeenSteps();
if (!seen.includes(stepId)) {
seen.push(stepId);
storage.set(SEEN_STEPS_KEY, JSON.stringify(seen));
}
},
getPendingSteps(): OnboardingStep[] {
const lastVersion = this.getCompletedVersion();
const seenIds = this.getSeenSteps();
const folders = safDirectory.getAll();
return ONBOARDING_STEPS.filter((step) => {
if (lastVersion != null && step.version <= lastVersion) return false;
if (seenIds.includes(step.id)) return false;
if (step.condition === 'has_no_folders' && folders.length > 0) return false;
return true;
});
},
needsOnboarding(): boolean {
return this.getPendingSteps().length > 0;
},
reset() {
storage.remove(COMPLETED_VERSION_KEY);
storage.remove(SEEN_STEPS_KEY);
},
};
-179
View File
@@ -1,179 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
export type SyncMode = 'none' | 'manual' | 'auto';
export type SyncGlobalMode = 'off' | 'auto' | 'manual';
export type FolderSource = 'saf' | 'media-library' | 'recursive';
export type StoredFolder = {
id: string;
uri: string;
name: string;
visible: boolean;
syncMode: SyncMode;
syncCellular: boolean;
source: FolderSource;
albumId?: string;
parentUri?: string;
};
const storage = createMMKV({ id: 'vaultdrop-saf' });
const FOLDERS_KEY = 'saf_folders';
const SYNC_GLOBAL_MODE_KEY = 'sync_global_mode';
const SYNC_GLOBAL_CELLULAR_KEY = 'sync_global_cellular';
const DISCOVERED_KEY = 'folders_discovered';
function generateId(): string {
return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function getAllRaw(): StoredFolder[] {
const raw = storage.getString(FOLDERS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as StoredFolder[];
let migrated = false;
for (const f of parsed) {
if (f.syncMode === undefined) {
(f as any).syncMode = 'none';
migrated = true;
}
if (f.syncCellular === undefined) {
(f as any).syncCellular = false;
migrated = true;
}
if (f.source === undefined) {
(f as any).source = 'saf';
migrated = true;
}
}
if (migrated) saveAll(parsed);
return parsed;
}
function saveAll(folders: StoredFolder[]) {
storage.set(FOLDERS_KEY, JSON.stringify(folders));
}
export const safDirectory = {
getAll(): StoredFolder[] {
return getAllRaw();
},
getVisibleFolders(): StoredFolder[] {
return getAllRaw().filter((f) => f.visible);
},
addFolder(uri: string, name: string): StoredFolder {
const folders = getAllRaw();
if (folders.some((f) => f.uri === uri)) {
return folders.find((f) => f.uri === uri)!;
}
const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' };
folders.push(folder);
saveAll(folders);
return folder;
},
addMediaFolder(albumId: string, name: string, uri: string): StoredFolder {
const folders = getAllRaw();
const key = `media://${albumId}`;
if (folders.some((f) => f.uri === key)) {
return folders.find((f) => f.uri === key)!;
}
const folder: StoredFolder = {
id: generateId(),
uri: key,
name,
visible: true,
syncMode: 'none',
syncCellular: false,
source: 'media-library',
albumId,
};
folders.push(folder);
saveAll(folders);
return folder;
},
addBatchFolders(folders: Array<{ uri: string; name: string; source?: FolderSource; parentUri?: string }>): StoredFolder[] {
const current = getAllRaw();
const added: StoredFolder[] = [];
for (const f of folders) {
if (current.some((existing) => existing.uri === f.uri)) continue;
const folder: StoredFolder = {
id: generateId(),
uri: f.uri,
name: f.name,
visible: true,
syncMode: 'none',
syncCellular: false,
source: f.source ?? 'recursive',
parentUri: f.parentUri,
};
current.push(folder);
added.push(folder);
}
saveAll(current);
return added;
},
getDiscovered(): boolean {
return storage.getString(DISCOVERED_KEY) === 'true';
},
setDiscovered() {
storage.set(DISCOVERED_KEY, 'true');
},
resetDiscovered() {
storage.remove(DISCOVERED_KEY);
},
removeFolder(id: string) {
const folders = getAllRaw().filter((f) => f.id !== id);
saveAll(folders);
},
toggleVisibility(id: string) {
const folders = getAllRaw().map((f) =>
f.id === id ? { ...f, visible: !f.visible } : f
);
saveAll(folders);
},
updateSyncMode(id: string, syncMode: SyncMode) {
const folders = getAllRaw().map((f) =>
f.id === id ? { ...f, syncMode } : f
);
saveAll(folders);
},
updateSyncCellular(id: string, syncCellular: boolean) {
const folders = getAllRaw().map((f) =>
f.id === id ? { ...f, syncCellular } : f
);
saveAll(folders);
},
getGlobalSyncMode(): SyncGlobalMode {
const raw = storage.getString(SYNC_GLOBAL_MODE_KEY);
if (raw === 'auto' || raw === 'manual') return raw;
return 'off';
},
setGlobalSyncMode(mode: SyncGlobalMode) {
storage.set(SYNC_GLOBAL_MODE_KEY, mode);
},
getGlobalSyncCellular(): boolean {
return storage.getString(SYNC_GLOBAL_CELLULAR_KEY) === 'true';
},
setGlobalSyncCellular(enabled: boolean) {
storage.set(SYNC_GLOBAL_CELLULAR_KEY, enabled ? 'true' : 'false');
},
clear() {
storage.remove(FOLDERS_KEY);
},
};
-25
View File
@@ -1,25 +0,0 @@
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
const THUMB_SIZE = 128;
function isGeneratableImage(mimeType: string): boolean {
return (mimeType ?? '').toLowerCase().startsWith('image/');
}
export async function generateLocalThumbnail(
uri: string | undefined,
mimeType: string,
): Promise<string | null> {
if (!uri || !isGeneratableImage(mimeType)) return null;
try {
const result = await manipulateAsync(
uri,
[{ resize: { width: THUMB_SIZE } }],
{ format: SaveFormat.JPEG, compress: 0.7, base64: true },
);
if (!result.base64) return null;
return `data:image/jpeg;base64,${result.base64}`;
} catch {
return null;
}
}
-343
View File
@@ -1,343 +0,0 @@
import { File, UploadType } from 'expo-file-system';
import { createMMKV } from 'react-native-mmkv';
import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, UploadError } from '../types';
import { fileStore } from './fileStore';
export type UploadFile = { uri: string; type: string; name: string };
export type UploadResult = { name: string; id: string };
export const UPLOAD_MAX_RETRIES = 3;
const BASE_RETRY_DELAY_MS = 1000;
export const activeUploadUris = new Set<string>();
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
export type UploadTask = {
id: string;
file: UploadFile;
status: UploadTaskStatus;
progress: number;
result?: UploadResult;
error?: string;
retryCount: number;
createdAt: number;
updatedAt: number;
};
type Listener = () => void;
const storage = createMMKV({ id: 'vaultdrop-upload-queue' });
const STORAGE_KEY = 'tasks';
function serialize(task: UploadTask): unknown {
return {
id: task.id,
file: task.file,
status: task.status,
progress: task.progress,
result: task.result ?? null,
error: task.error ?? null,
retryCount: task.retryCount,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}
function deserialize(data: unknown): UploadTask | null {
const d = data as Record<string, unknown>;
if (!d || !d.id || !d.file) return null;
const file = d.file as Record<string, string>;
if (!file.uri || !file.type || !file.name) return null;
return {
id: d.id as string,
file: { uri: file.uri, type: file.type, name: file.name },
status: d.status as UploadTaskStatus,
progress: d.progress as number,
result: d.result ? (d.result as UploadResult) : undefined,
error: d.error ? (d.error as string) : undefined,
retryCount: (d.retryCount as number) ?? 0,
createdAt: d.createdAt as number,
updatedAt: d.updatedAt as number,
};
}
function saveTasks(tasks: UploadTask[]) {
const persistable = tasks
.filter((t) => t.status !== 'done')
.map(serialize);
storage.set(STORAGE_KEY, JSON.stringify(persistable));
}
function loadTasks(): UploadTask[] {
try {
const raw = storage.getString(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown[] = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const tasks: UploadTask[] = [];
let maxId = 0;
for (const item of parsed) {
const t = deserialize(item);
if (t) {
tasks.push(t);
const num = parseInt(t.id.replace('upload_', ''), 10);
if (num > maxId) maxId = num;
}
}
nextId = maxId;
return tasks;
} catch {
return [];
}
}
let nextId = 0;
function genId() {
nextId++;
return `upload_${Date.now()}_${nextId}`;
}
class UploadQueue {
private tasks: UploadTask[] = [];
private listeners = new Set<Listener>();
private concurrency = 3;
private active = 0;
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.tasks = loadTasks();
const pendingExist = this.tasks.some(
(t) => t.status === 'pending' || t.status === 'uploading',
);
if (pendingExist) {
setTimeout(() => {
this.restartUploading();
this.processNext();
}, 0);
}
}
private restartUploading() {
for (const task of this.tasks) {
if (task.status === 'uploading') {
task.status = 'pending';
task.progress = 0;
task.updatedAt = Date.now();
}
}
this.persist();
}
getTasks(): UploadTask[] {
return this.tasks;
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify() {
this.listeners.forEach((l) => l());
}
private persist() {
saveTasks(this.tasks);
}
enqueue(files: UploadFile[]) {
const now = Date.now();
for (const file of files) {
this.tasks.push({
id: genId(),
file,
status: 'pending',
progress: 0,
retryCount: 0,
createdAt: now,
updatedAt: now,
});
}
this.persist();
this.notify();
this.processNext();
}
cancel(id: string) {
const task = this.tasks.find((t) => t.id === id);
if (!task || task.status === 'done') return;
task.status = 'error';
task.error = 'Annulé';
task.updatedAt = Date.now();
this.persist();
this.notify();
}
retry(id: string) {
const task = this.tasks.find((t) => t.id === id);
if (!task || task.status !== 'error') return;
task.status = 'pending';
task.progress = 0;
task.retryCount = 0;
task.error = undefined;
task.result = undefined;
task.updatedAt = Date.now();
this.persist();
this.notify();
this.processNext();
}
retryAll() {
for (const task of this.tasks) {
if (task.status === 'error') {
task.status = 'pending';
task.progress = 0;
task.retryCount = 0;
task.error = undefined;
task.result = undefined;
task.updatedAt = Date.now();
}
}
this.persist();
this.notify();
this.processNext();
}
getPendingCount(): number {
return this.tasks.filter((t) => t.status === 'pending' || t.status === 'uploading').length;
}
private processNext() {
while (this.active < this.concurrency) {
const next = this.tasks.find((t) => t.status === 'pending');
if (!next) break;
this.active++;
next.status = 'uploading';
next.updatedAt = Date.now();
this.persist();
this.notify();
this.runTask(next);
}
}
private async runTask(task: UploadTask) {
let willRetry = false;
activeUploadUris.add(task.file.uri);
try {
const fsFile = new File(task.file.uri);
const headers: Record<string, string> = {};
const token = apiClient.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
httpMethod: 'POST',
uploadType: UploadType.MULTIPART,
fieldName: 'file',
mimeType: task.file.type,
headers,
onProgress: (progress) => {
if (progress.totalBytes > 0) {
task.progress = Math.round((progress.bytesSent / progress.totalBytes) * 100);
this.notify();
}
},
});
if (result.status >= 400) {
let serverMessage = 'Erreur serveur';
try {
const body: ApiError = JSON.parse(result.body);
serverMessage = body.error?.message || serverMessage;
} catch {
serverMessage = result.body || serverMessage;
}
throw new UploadError(task.file.name, result.status, serverMessage);
}
const body = JSON.parse(result.body);
const items = body.data ?? body;
const item = Array.isArray(items) ? items[0] : items;
task.status = 'done';
task.progress = 100;
task.result = item as UploadResult;
task.updatedAt = Date.now();
this.linkResultToStore(task);
this.persist();
this.notify();
this.scheduleCleanup();
} catch (err) {
task.retryCount++;
if (task.retryCount <= UPLOAD_MAX_RETRIES) {
willRetry = true;
task.status = 'pending';
task.progress = 0;
task.error = undefined;
task.updatedAt = Date.now();
this.persist();
this.notify();
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, task.retryCount - 1);
setTimeout(() => this.processNext(), delay);
} else {
task.status = 'error';
task.error =
err instanceof UploadError
? `${err.fileName} : ${err.message}`
: err instanceof Error
? err.message
: 'Erreur inconnue';
task.error += ` (${task.retryCount} tentative(s))`;
task.updatedAt = Date.now();
this.persist();
this.notify();
}
} finally {
this.active--;
activeUploadUris.delete(task.file.uri);
this.notify();
if (!willRetry) {
this.processNext();
}
}
}
private linkResultToStore(task: UploadTask) {
try {
const backendId = task.result?.id;
if (!backendId) return;
const entry = fileStore.getByLocalUri(task.file.uri);
if (!entry || entry.backendId) return;
fileStore.updatePartial(entry.id, {
backendId,
syncStatus: 'synced',
source: 'synced',
});
} catch {}
}
private scheduleCleanup() {
if (this.cleanupTimer) return;
this.cleanupTimer = setTimeout(() => {
this.cleanupTimer = null;
const now = Date.now();
const before = this.tasks.length;
this.tasks = this.tasks.filter(
(t) => t.status !== 'done' || now - t.updatedAt < 5000,
);
if (this.tasks.length !== before) {
this.persist();
this.notify();
}
if (this.tasks.some((t) => t.status === 'done')) {
this.scheduleCleanup();
}
}, 6000);
}
}
export const uploadQueue = new UploadQueue();
-181
View File
@@ -1,181 +0,0 @@
export interface Variant {
id: string;
pageNumber: number;
variantType: string;
width: number;
height: number;
url: string;
mimeType: string;
}
export interface UnifiedFileItem {
id: string;
backendResourceId?: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
updatedAt?: string;
source: 'cloud' | 'local' | 'synced';
syncStatus: SyncStatus;
localUri?: string;
ocrText?: string;
tags: Tag[];
isFolder: boolean;
parentResourceId?: string;
ownerId?: string;
url?: string;
thumbnailUrl?: string;
thumbnailLocal?: string;
variants?: Variant[];
isDeviceFile?: boolean;
isUploading?: boolean;
uploadProgress?: number;
uploadStatus?: 'pending' | 'uploading' | 'done' | 'error';
}
export type FileItem = UnifiedFileItem;
export function isFolder(file: UnifiedFileItem | { isFolder: boolean }): boolean {
return file.isFolder;
}
export interface Tag {
id: string;
tag_name: string;
}
export interface OcrJob {
id: string;
fileId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
result?: string;
createdAt: string;
completedAt?: string;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
page: number;
total: number;
};
}
export interface ApiError {
error: {
code: string;
message: string;
};
}
export class HttpError extends Error {
status: number;
code?: string;
constructor(status: number, message: string, code?: string) {
super(message);
this.name = 'HttpError';
this.status = status;
this.code = code;
}
}
export class UploadError extends HttpError {
fileName: string;
constructor(fileName: string, status: number, message: string, code?: string) {
super(status, message, code);
this.name = 'UploadError';
this.fileName = fileName;
}
}
export type CapturedPhoto = {
id: string;
filePath: string;
uri: string;
uploadedId?: string;
uploadedAt?: string;
};
export type Batch = {
id: string;
name: string;
createdAt: string;
photos: CapturedPhoto[];
tags: string[];
};
export interface User {
id: string;
username: string;
}
export interface AuthTokens {
access_token: string;
refresh_token: string;
}
export interface AuthResponse {
user: User;
access_token: string;
refresh_token: string;
}
export interface RefreshResponse {
access_token: string;
refresh_token: string;
}
export type SseEvent = {
event: string;
data: string;
};
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
export interface Device {
id: string;
device_name: string;
role: string;
}
export interface SyncQueueItem {
id: string;
resourceId: string;
storageLocationId: string;
operation: string;
status: string;
attempts: number;
createdAt: string;
updatedAt: string;
}
export interface ShareEntry {
user_id: string;
role: string;
}
export interface ResourcePlacement {
id: string;
resourceId: string;
storageLocationId: string;
status: string;
storageKey: string | null;
syncedAt: string | null;
}
export type PendingActionType = 'tag_add' | 'delete' | 'move' | 'create_folder';
export type PendingActionStatus = 'pending' | 'done' | 'error' | 'obsolete';
export interface PendingAction {
id: string;
type: PendingActionType;
payload: Record<string, unknown>;
status: PendingActionStatus;
attempts: number;
lastError: string | null;
resourceId: string | null;
createdAt: string;
}