from scratch
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
+18
-138
@@ -1,144 +1,24 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ActivityIndicator, 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>
|
||||
);
|
||||
}
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24h
|
||||
}}
|
||||
>
|
||||
<AppContent />
|
||||
</PersistQueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Dot.</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -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);
|
||||
@@ -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
@@ -2,42 +2,25 @@
|
||||
"expo": {
|
||||
"name": "webui",
|
||||
"slug": "webui",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "VaultDrop a besoin d'accéder à votre caméra pour scanner des documents."
|
||||
},
|
||||
"bundleIdentifier": "com.anonymous.webui"
|
||||
},
|
||||
"android": {
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"RECEIVE_BOOT_COMPLETED"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.anonymous.webui"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store",
|
||||
"expo-image",
|
||||
"expo-sqlite",
|
||||
"expo-background-task",
|
||||
"expo-status-bar"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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%',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,5 @@ module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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: {},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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' }],
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(), []),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
-77
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-169
@@ -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
|
||||
}
|
||||
}
|
||||
-105
@@ -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';
|
||||
Generated
+695
-3022
File diff suppressed because it is too large
Load Diff
+4
-34
@@ -1,46 +1,16 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"main": "index.ts",
|
||||
"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-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-task-manager": "~57.0.6",
|
||||
"react": "19.2.3",
|
||||
"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"
|
||||
"react-native": "0.86.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/metro-config": "^57.0.7",
|
||||
"@types/react": "~19.2.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"babel-preset-expo": "^57.0.4",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -50,4 +20,4 @@
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { initDB } from './index';
|
||||
|
||||
export function migrateFromLegacy() {
|
||||
initDB();
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user