from scratch
This commit is contained in:
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"enabledPlugins": {
|
|
||||||
"expo@claude-plugins-official": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-137
@@ -1,144 +1,24 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
|
||||||
import { DeviceProvider, useDevice } from './contexts/DeviceContext';
|
|
||||||
import { SseProvider } from './contexts/SseContext';
|
|
||||||
import { SseOcrListener } from './hooks/usePollOcr';
|
|
||||||
import { SseResourceListener } from './hooks/useSSEResource';
|
|
||||||
import { registerBackgroundUpload } from './services/backgroundUpload';
|
|
||||||
import { LoginScreen } from './app/login';
|
|
||||||
import { RegisterScreen } from './app/register';
|
|
||||||
import { HomeScreen } from './app/index';
|
|
||||||
import { ScanScreen } from './app/scan';
|
|
||||||
import { SearchScreen } from './app/search';
|
|
||||||
import { BatchReviewScreen } from './app/batch-review';
|
|
||||||
import { PendingReviewScreen } from './app/pending-review';
|
|
||||||
import { FileDetailScreen } from './app/file-detail';
|
|
||||||
import { FileEditScreen } from './app/file-edit';
|
|
||||||
import { FolderScreen } from './app/folder';
|
|
||||||
import { OnboardingScreen } from './app/onboarding';
|
|
||||||
import { SyncDetailScreen } from './app/sync-detail';
|
|
||||||
import { onboardingStorage } from './services/onboardingStorage';
|
|
||||||
import { initDB } from './services/fileStore';
|
|
||||||
import { migrateFromLegacy } from './services/fileStore/migrate';
|
|
||||||
import { createMMKVPersister } from './services/mmkvPersister';
|
|
||||||
import { resetSyncState } from './hooks/useSyncQueue';
|
|
||||||
|
|
||||||
initDB();
|
export default function App() {
|
||||||
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 (
|
return (
|
||||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
<View style={styles.container}>
|
||||||
<ActivityIndicator size="large" />
|
<Text style={styles.title}>Dot.</Text>
|
||||||
|
<StatusBar style="auto" />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsOnboarding = user && onboardingStorage.needsOnboarding();
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
return (
|
flex: 1,
|
||||||
<NavigationContainer>
|
backgroundColor: '#fff',
|
||||||
<Stack.Navigator
|
alignItems: 'center',
|
||||||
initialRouteName={
|
justifyContent: 'center',
|
||||||
needsOnboarding ? 'Onboarding'
|
},
|
||||||
: user ? 'Home'
|
title: {
|
||||||
: 'Login'
|
fontSize: 32,
|
||||||
}
|
fontWeight: '600',
|
||||||
>
|
},
|
||||||
{user ? (
|
});
|
||||||
<>
|
|
||||||
<Stack.Screen
|
|
||||||
name="Onboarding"
|
|
||||||
component={OnboardingScreen}
|
|
||||||
options={{ headerShown: false }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="Home"
|
|
||||||
component={HomeScreen}
|
|
||||||
options={{
|
|
||||||
title: 'Dot.',
|
|
||||||
headerTitleStyle: { fontSize: 18 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Scan' }} />
|
|
||||||
<Stack.Screen name="Search" component={SearchScreen} options={{ title: 'Recherche' }} />
|
|
||||||
<Stack.Screen name="BatchReview" component={BatchReviewScreen} options={{ title: 'Revue du lot' }} />
|
|
||||||
<Stack.Screen name="PendingReview" component={PendingReviewScreen} options={{ title: 'Réorganisation' }} />
|
|
||||||
<Stack.Screen
|
|
||||||
name="FileDetail"
|
|
||||||
component={FileDetailScreen}
|
|
||||||
options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' }, animation: 'fade' }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
|
|
||||||
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
|
|
||||||
<Stack.Screen name="SyncDetail" component={SyncDetailScreen} options={{ title: 'Synchronisation' }} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="Register" component={RegisterScreen} options={{ headerShown: false }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack.Navigator>
|
|
||||||
</NavigationContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppContent() {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<SseProvider>
|
|
||||||
<SseOcrListener />
|
|
||||||
<SseResourceListener />
|
|
||||||
<DeviceProvider>
|
|
||||||
<AppNavigator />
|
|
||||||
<StatusBar style="auto" />
|
|
||||||
</DeviceProvider>
|
|
||||||
</SseProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<PersistQueryClientProvider
|
|
||||||
client={queryClient}
|
|
||||||
persistOptions={{
|
|
||||||
persister,
|
|
||||||
maxAge: 1000 * 60 * 60 * 24, // 24h
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppContent />
|
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+2
-19
@@ -2,42 +2,25 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "webui",
|
"name": "webui",
|
||||||
"slug": "webui",
|
"slug": "webui",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
|
||||||
"NSCameraUsageDescription": "VaultDrop a besoin d'accéder à votre caméra pour scanner des documents."
|
|
||||||
},
|
|
||||||
"bundleIdentifier": "com.anonymous.webui"
|
"bundleIdentifier": "com.anonymous.webui"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"permissions": [
|
|
||||||
"CAMERA",
|
|
||||||
"READ_EXTERNAL_STORAGE",
|
|
||||||
"WRITE_EXTERNAL_STORAGE",
|
|
||||||
"RECEIVE_BOOT_COMPLETED"
|
|
||||||
],
|
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/android-icon-background.png",
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
|
||||||
"package": "com.anonymous.webui"
|
"package": "com.anonymous.webui"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
}
|
||||||
"plugins": [
|
|
||||||
"expo-secure-store",
|
|
||||||
"expo-image",
|
|
||||||
"expo-sqlite",
|
|
||||||
"expo-background-task",
|
|
||||||
"expo-status-bar"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ['babel-preset-expo'],
|
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
+3
-33
@@ -1,46 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "webui",
|
"name": "webui",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
|
||||||
"@react-native-community/netinfo": "12.0.1",
|
|
||||||
"@react-navigation/native": "^7.3.8",
|
|
||||||
"@react-navigation/native-stack": "^7.17.10",
|
|
||||||
"@tanstack/query-async-storage-persister": "^5.101.4",
|
|
||||||
"@tanstack/react-query": "^5.101.2",
|
|
||||||
"@tanstack/react-query-persist-client": "^5.101.4",
|
|
||||||
"babel-preset-expo": "^57.0.4",
|
|
||||||
"drizzle-orm": "^0.45.2",
|
|
||||||
"expo": "~57.0.8",
|
"expo": "~57.0.8",
|
||||||
"expo-background-task": "~57.0.6",
|
|
||||||
"expo-blur": "~57.0.2",
|
|
||||||
"expo-document-picker": "~57.0.1",
|
|
||||||
"expo-file-system": "~57.0.1",
|
|
||||||
"expo-haptics": "~57.0.2",
|
|
||||||
"expo-image": "~57.0.1",
|
|
||||||
"expo-image-manipulator": "~57.0.14",
|
|
||||||
"expo-print": "~57.0.1",
|
|
||||||
"expo-secure-store": "~57.0.1",
|
|
||||||
"expo-sqlite": "~57.0.1",
|
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"expo-task-manager": "~57.0.6",
|
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.86.0",
|
"react-native": "0.86.0"
|
||||||
"react-native-gesture-handler": "~2.32.0",
|
|
||||||
"react-native-mmkv": "^4.3.2",
|
|
||||||
"react-native-nitro-image": "^0.15.1",
|
|
||||||
"react-native-nitro-modules": "^0.36.1",
|
|
||||||
"react-native-reanimated": "4.5.0",
|
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
|
||||||
"react-native-screens": "~4.26.0",
|
|
||||||
"react-native-vision-camera": "^5.1.0",
|
|
||||||
"react-native-worklets": "0.10.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@expo/metro-config": "^57.0.7",
|
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
"drizzle-kit": "^0.31.10",
|
"babel-preset-expo": "^57.0.4",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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