from scratch
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user