from scratch

This commit is contained in:
m
2026-09-09 18:23:01 +02:00
parent c7467ea8f9
commit 2ba837bcb0
87 changed files with 721 additions and 15218 deletions
-234
View File
@@ -1,234 +0,0 @@
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useBatchStore } from '../hooks/useBatchStore';
import { usePdfGeneration } from '../hooks/usePdfGeneration';
import { ConfirmModal } from '../components/ConfirmModal';
type RootStackParamList = {
Home: undefined;
BatchReview: { batchId: string };
PendingReview: { batchId: string; photoIds: string[] };
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type BatchReviewRouteParams = { BatchReview: { batchId: string } };
export function BatchReviewScreen() {
const route = useRoute<RouteProp<BatchReviewRouteParams, 'BatchReview'>>();
const navigation = useNavigation<NavigationProp>();
const { getBatch, removePhotoFromBatch } = useBatchStore();
const batch = getBatch(route.params.batchId);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
if (!batch) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Lot introuvable</Text>
</View>
);
}
const toggleSelection = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectedCount = selectedIds.size;
const handleDelete = () => {
if (selectedCount === 0) return;
setConfirmDeleteVisible(true);
};
const handleDeleteConfirm = () => {
selectedIds.forEach((id) => removePhotoFromBatch(batch.id, id));
setSelectedIds(new Set());
};
const handleGroup = () => {
if (selectedCount === 0) return;
const ids = Array.from(selectedIds);
navigation.navigate('PendingReview', { batchId: batch.id, photoIds: ids });
};
const formatDate = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{batch.name}</Text>
<Text style={styles.subtitle}>
{batch.photos.length} photo{batch.photos.length > 1 ? 's' : ''} {formatDate(batch.createdAt)}
</Text>
</View>
<FlatList
data={batch.photos}
numColumns={3}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.grid}
renderItem={({ item }) => {
const isSelected = selectedIds.has(item.id);
return (
<TouchableOpacity
style={[styles.gridItem, isSelected && styles.gridItemSelected]}
onPress={() => toggleSelection(item.id)}
>
<Image source={{ uri: item.uri }} style={styles.thumb} />
{isSelected && (
<View style={styles.selectedOverlay}>
<Text style={styles.selectedCheck}></Text>
</View>
)}
</TouchableOpacity>
);
}}
/>
<View style={styles.footer}>
<Text style={styles.selectionInfo}>
{selectedCount > 0 ? `${selectedCount} sélectionnée${selectedCount > 1 ? 's' : ''}` : 'Touchez une photo pour sélectionner'}
</Text>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.actionBtn, styles.deleteBtn, selectedCount === 0 && styles.actionBtnDisabled]}
onPress={handleDelete}
disabled={selectedCount === 0}
>
<Text style={styles.actionText}>🗑 Supprimer</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.groupBtn, selectedCount === 0 && styles.actionBtnDisabled]}
onPress={handleGroup}
disabled={selectedCount === 0}
>
<Text style={styles.actionText}>📦 Regrouper</Text>
</TouchableOpacity>
</View>
</View>
<ConfirmModal
visible={confirmDeleteVisible}
title="Supprimer"
message={`Retirer ${selectedCount === 1 ? 'cette photo' : `ces ${selectedCount} photos`} du lot ?`}
options={[
{ label: 'Annuler' },
{ label: 'Supprimer', destructive: true, onPress: handleDeleteConfirm },
]}
onClose={() => setConfirmDeleteVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: '#666',
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#666',
},
grid: {
padding: 4,
},
gridItem: {
flex: 1 / 3,
aspectRatio: 1,
margin: 4,
borderRadius: 6,
overflow: 'hidden',
backgroundColor: '#f0f0f0',
},
gridItemSelected: {
borderWidth: 3,
borderColor: '#1976D2',
},
thumb: {
width: '100%',
height: '100%',
},
selectedOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(25,118,210,0.3)',
justifyContent: 'center',
alignItems: 'center',
},
selectedCheck: {
color: '#fff',
fontSize: 32,
fontWeight: '700',
},
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 12,
},
selectionInfo: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
actions: {
flexDirection: 'row',
gap: 12,
},
actionBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
actionBtnDisabled: {
opacity: 0.4,
},
deleteBtn: {
backgroundColor: '#F44336',
},
groupBtn: {
backgroundColor: '#4CAF50',
},
actionText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
-129
View File
@@ -1,129 +0,0 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Platform } from 'react-native';
import { useNavigation, CommonActions } from '@react-navigation/native';
import { useDevice } from '../contexts/DeviceContext';
export function DeviceSetupScreen() {
const navigation = useNavigation();
const { register, deviceName } = useDevice();
const [name, setName] = useState(deviceName || `${Platform.OS.charAt(0).toUpperCase() + Platform.OS.slice(1)} de ${Platform.OS === 'ios' ? 'l\'utilisateur' : 'utilisateur'}`);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRegister = async () => {
if (!name.trim()) return;
setIsLoading(true);
setError(null);
try {
await register(name.trim());
navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'Home' }] }));
} catch (err: any) {
setError(err?.message || "Échec de l'enregistrement");
} finally {
setIsLoading(false);
}
};
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.icon}>📱</Text>
<Text style={styles.title}>Enregistrement du device</Text>
<Text style={styles.subtitle}>
Ce device doit être enregistré comme espace de stockage pour utiliser VaultDrop.
</Text>
<TextInput
style={styles.input}
placeholder="Nom du device"
placeholderTextColor="#999"
value={name}
onChangeText={setName}
autoFocus
returnKeyType="done"
onSubmitEditing={handleRegister}
/>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity
style={[styles.button, !name.trim() && styles.buttonDisabled]}
onPress={handleRegister}
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Enregistrer</Text>
)}
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
justifyContent: 'center',
padding: 24,
},
content: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 24,
alignItems: 'center',
gap: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
icon: {
fontSize: 48,
},
title: {
fontSize: 22,
fontWeight: '700',
color: '#333',
textAlign: 'center',
},
subtitle: {
fontSize: 15,
color: '#666',
textAlign: 'center',
lineHeight: 22,
},
input: {
width: '100%',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
color: '#333',
},
error: {
color: '#E53935',
fontSize: 14,
textAlign: 'center',
},
button: {
width: '100%',
backgroundColor: '#1976D2',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
},
buttonDisabled: {
backgroundColor: '#ccc',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
-697
View File
@@ -1,697 +0,0 @@
import React, { useRef, useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
Dimensions,
ActivityIndicator,
Modal,
Pressable,
TouchableOpacity,
Share,
} from 'react-native';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFile, useDownloadFile } from '../hooks/useFiles';
import { fileStore } from '../services/fileStore';
import { TagChip } from '../components/TagChip';
import { FileThumbnail } from '../components/FileThumbnail';
import { ConfirmModal } from '../components/ConfirmModal';
import { SyncStatusBadge } from '../components/SyncStatusBadge';
import { GestureHandlerRootView, Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS } from 'react-native-reanimated';
import { ZoomableImage } from '../components/ZoomableImage';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { downloadRegistry } from '../services/downloadRegistry';
import { deleteAsync } from 'expo-file-system/legacy';
import type { Variant, SyncStatus } from '../types';
const SCREEN_WIDTH = Dimensions.get('window').width;
type SelectedImage = { uri: string; width: number; height: number };
type ModalState = { images: SelectedImage[]; index: number } | null;
export type DeviceFileParam = {
localUri: string;
name: string;
mimeType: string;
createdAt: string;
};
type RootStackParamList = {
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, DeviceFileParam> };
};
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
const PANEL_HEIGHT = 410;
const PANEL_HEADER_VISIBLE = 100;
function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; deviceFile?: DeviceFileParam; onSelectImage?: (state: ModalState) => void }) {
const isDevice = !!deviceFile;
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const localEntry = fileStore.getById(fileId);
const apiId = isDevice ? '' : (localEntry?.backendId ?? fileId);
const { data: fileData } = useFile(apiId);
const downloadFile = useDownloadFile();
const [downloading, setDownloading] = useState(false);
const file = fileData as any;
const uri = isDevice ? deviceFile.localUri : (localEntry?.localUri ?? file?.url);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
const handleImageLoad = useCallback((event: { source: { width: number; height: number } }) => {
setImageSize({ width: event.source.width, height: event.source.height });
}, []);
const syncStatus: SyncStatus = isDevice
? 'local'
: localEntry
? (localEntry.syncStatus as SyncStatus)
: 'cloud';
const fullVariants: Variant[] = (file?.variants ?? [])
.filter((v: Variant) => v.variantType === 'thumbnail_full')
.sort((a: Variant, b: Variant) => a.pageNumber - b.pageNumber);
const fullThumbnails = fullVariants;
const hasPages = fullThumbnails.length > 0;
const fileName = deviceFile?.name ?? file?.name ?? fileId;
const createdAt = deviceFile?.createdAt ?? file?.createdAt;
const formattedDate = createdAt
? new Date(createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
const fileSize = file?.size ?? 0;
const formattedSize = fileSize > 0
? fileSize > 1024 * 1024
? `${(fileSize / (1024 * 1024)).toFixed(1)} Mo`
: `${(fileSize / 1024).toFixed(1)} Ko`
: '';
const [optionsVisible, setOptionsVisible] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
const panelOffset = useSharedValue(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
const panelStartY = useSharedValue(0);
const isPanelExpanded = useSharedValue(false);
const [panelExpanded, setPanelExpanded] = useState(false);
const handleDownload = useCallback(async () => {
if (!file) return;
const bid = localEntry?.backendId ?? fileId;
setDownloading(true);
try {
await downloadFile.mutateAsync({
id: bid,
backendResourceId: bid,
name: fileName,
mimeType: file?.mimeType ?? localEntry?.mimeType ?? 'application/octet-stream',
size: file?.size ?? localEntry?.size ?? 0,
createdAt: file?.createdAt ?? localEntry?.createdAt ?? new Date().toISOString(),
source: 'cloud',
syncStatus: 'cloud',
tags: file?.tags ?? [],
isFolder: false,
});
} catch {} finally {
setDownloading(false);
}
}, [file, fileName, fileId, localEntry, downloadFile]);
const handleShare = useCallback(async () => {
setOptionsVisible(false);
if (uri) {
await Share.share({ url: uri, title: fileName });
} else if (file?.url) {
await Share.share({ url: file.url, title: fileName });
}
}, [uri, fileName, file?.url]);
const handleDeleteConfirm = useCallback(async () => {
setDeleting(true);
try {
const bid = localEntry?.backendId ?? (file as any)?.backendResourceId;
if (bid) {
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${bid}`);
fileStore.deleteByBackendId(bid);
} else {
fileStore.deleteById(fileId);
}
if (localEntry?.localUri) {
await deleteAsync(localEntry.localUri, { idempotent: true });
}
downloadRegistry.remove(fileId);
navigation.goBack();
} catch {} finally {
setDeleting(false);
}
}, [fileName, fileId, localEntry, file, navigation]);
const handleDelete = useCallback(() => {
setOptionsVisible(false);
setConfirmDeleteVisible(true);
}, []);
const togglePanelJS = useCallback(() => {
if (isPanelExpanded.value) {
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
isPanelExpanded.value = false;
setPanelExpanded(false);
} else {
panelOffset.value = withSpring(0);
isPanelExpanded.value = true;
setPanelExpanded(true);
}
}, []);
const panGesture = Gesture.Pan()
.onStart(() => {
panelStartY.value = panelOffset.value;
})
.onUpdate((event) => {
const offset = Math.max(0, Math.min(PANEL_HEIGHT - PANEL_HEADER_VISIBLE, panelStartY.value + event.translationY));
panelOffset.value = offset;
})
.onEnd(() => {
if (panelOffset.value > (PANEL_HEIGHT - PANEL_HEADER_VISIBLE) / 2) {
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
isPanelExpanded.value = false;
runOnJS(setPanelExpanded)(false);
} else {
panelOffset.value = withSpring(0);
isPanelExpanded.value = true;
runOnJS(setPanelExpanded)(true);
}
});
const panelAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: panelOffset.value }],
}));
const renderImageContent = () => {
if (hasPages) {
return fullThumbnails.map((thumb, thumbIndex) => {
const allImages: SelectedImage[] = fullThumbnails.map((t) => ({
uri: t.url,
width: SCREEN_WIDTH,
height: t.height * (SCREEN_WIDTH / t.width),
}));
return (
<Pressable
key={thumb.id}
onPress={() => onSelectImage?.({ images: allImages, index: thumbIndex })}
>
<Image
source={{ uri: thumb.url }}
style={[styles.image, { height: thumb.height * (SCREEN_WIDTH / thumb.width) }]}
contentFit="contain"
cachePolicy="memory-disk"
transition={200}
/>
</Pressable>
);
});
}
if (uri) {
return (
<Image
source={{ uri }}
style={[
styles.image,
imageSize
? { height: imageSize.height * SCREEN_WIDTH / imageSize.width }
: { aspectRatio: 1 },
]}
contentFit="contain"
cachePolicy="memory-disk"
transition={200}
onLoad={handleImageLoad}
/>
);
}
if (file) {
return (
<View style={styles.cloudOnlyContainer}>
<FileThumbnail
thumbnailUrl={file?.thumbnailUrl}
mimeType={file?.mimeType ?? 'application/pdf'}
fileName={file?.name ?? fileId}
size={SCREEN_WIDTH * 0.5}
/>
{syncStatus === 'cloud' && (
<TouchableOpacity
style={styles.downloadBtn}
onPress={handleDownload}
disabled={downloading}
>
{downloading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<MaterialIcons name="cloud-download" size={20} color="#fff" />
)}
<Text style={styles.downloadBtnText}>
{downloading ? 'Téléchargement...' : 'Télécharger'}
</Text>
</TouchableOpacity>
)}
</View>
);
}
return <ActivityIndicator size="large" color="#1976D2" />;
};
if (deleting) {
return (
<View style={[styles.detailContainer, styles.center]}>
<ActivityIndicator size="large" color="#fff" />
</View>
);
}
return (
<View style={styles.detailContainer}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => {
if (!hasPages && uri) {
const height = imageSize
? imageSize.height * SCREEN_WIDTH / imageSize.width
: SCREEN_WIDTH;
onSelectImage?.({
images: [{ uri, width: SCREEN_WIDTH, height }],
index: 0,
});
}
}}
>
<View style={styles.imageCentered}>
{renderImageContent()}
</View>
</Pressable>
<View style={[styles.headerOverlay, { paddingTop: insets.top + 8 }]}>
<Text style={styles.headerDate} numberOfLines={1}>{formattedDate}</Text>
<TouchableOpacity onPress={() => setOptionsVisible(true)} style={styles.headerBtn}>
<MaterialIcons name="more-vert" size={24} color="#fff" />
</TouchableOpacity>
</View>
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.panel, { paddingBottom: insets.bottom + 12 }, panelAnimatedStyle]}>
<TouchableOpacity onPress={togglePanelJS} activeOpacity={0.7}>
<View style={styles.panelHandle} />
<View style={styles.panelHeader}>
<Text style={styles.panelFileName} numberOfLines={1}>{fileName}</Text>
<SyncStatusBadge status={syncStatus} size={20} />
</View>
</TouchableOpacity>
<View style={styles.panelBody}>
{formattedDate ? (
<View style={styles.metaRow}>
<MaterialIcons name="calendar-today" size={16} color="#888" />
<Text style={styles.metaText}>{formattedDate}</Text>
</View>
) : null}
{formattedSize ? (
<View style={styles.metaRow}>
<MaterialIcons name="storage" size={16} color="#888" />
<Text style={styles.metaText}>{formattedSize}</Text>
</View>
) : null}
{file?.tags && file.tags.length > 0 && (
<View style={styles.tagsSection}>
<Text style={styles.sectionLabel}>Tags</Text>
<View style={styles.tagsRow}>
{file.tags.map((tag: any) => (
<TagChip key={tag.id} name={tag.name} />
))}
</View>
</View>
)}
{file?.ocrText && (
<View style={styles.ocrSection}>
<Text style={styles.sectionLabel}>Texte OCR</Text>
<Text style={styles.ocrText} numberOfLines={4}>{file.ocrText}</Text>
</View>
)}
<View style={styles.actionsRow}>
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
<MaterialIcons name="share" size={20} color="#1976D2" />
<Text style={styles.actionBtnText}>Partager</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete}>
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
<Text style={[styles.actionBtnText, { color: '#E53935' }]}>Supprimer</Text>
</TouchableOpacity>
</View>
</View>
</Animated.View>
</GestureDetector>
<Modal visible={optionsVisible} transparent animationType="fade" onRequestClose={() => setOptionsVisible(false)}>
<TouchableOpacity style={styles.optionsOverlay} activeOpacity={1} onPress={() => setOptionsVisible(false)}>
<View style={styles.optionsMenu}>
<TouchableOpacity style={styles.optionItem} onPress={handleShare}>
<MaterialIcons name="share" size={22} color="#333" />
<Text style={styles.optionText}>Partager</Text>
</TouchableOpacity>
<View style={styles.optionDivider} />
<TouchableOpacity style={styles.optionItem} onPress={handleDelete}>
<MaterialIcons name="delete-outline" size={22} color="#E53935" />
<Text style={[styles.optionText, { color: '#E53935' }]}>Supprimer</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
<ConfirmModal
visible={confirmDeleteVisible}
title="Supprimer"
message={`Supprimer "${fileName}" définitivement ?`}
options={[
{ label: 'Annuler' },
{ label: 'Supprimer', destructive: true, onPress: handleDeleteConfirm },
]}
onClose={() => setConfirmDeleteVisible(false)}
/>
</View>
);
}
export function FileDetailScreen() {
const route = useRoute<FileDetailRouteProp>();
const { fileIds, initialIndex, deviceFiles } = route.params;
const flatListRef = useRef<FlatList>(null);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [modalState, setModalState] = useState<ModalState>(null);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
setModalState((prev) => {
if (!prev) return prev;
const next = direction === 'up'
? Math.min(prev.index + 1, prev.images.length - 1)
: Math.max(prev.index - 1, 0);
if (next === prev.index) return prev;
return { ...prev, index: next };
});
}, []);
return (
<View style={styles.container}>
<FlatList
ref={flatListRef}
data={fileIds}
keyExtractor={(item) => item}
horizontal
snapToInterval={SCREEN_WIDTH}
decelerationRate="fast"
disableIntervalMomentum
showsHorizontalScrollIndicator={false}
initialScrollIndex={initialIndex}
windowSize={5}
initialNumToRender={3}
maxToRenderPerBatch={3}
getItemLayout={(_, index) => ({
length: SCREEN_WIDTH,
offset: SCREEN_WIDTH * index,
index,
})}
onMomentumScrollEnd={(e) => {
const index = Math.round(e.nativeEvent.contentOffset.x / SCREEN_WIDTH);
setCurrentIndex(index);
}}
renderItem={({ item }) => (
<View style={styles.pageWrapper}>
<DetailItem fileId={item} deviceFile={deviceFiles?.[item]} onSelectImage={setModalState} />
</View>
)}
/>
<View style={styles.pagination}>
<Text style={styles.paginationText}>
{currentIndex + 1} / {fileIds.length}
</Text>
</View>
<Modal
visible={modalState !== null}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setModalState(null)}
>
{modalState && (
<GestureHandlerRootView style={{ flex: 1 }}>
<ZoomableImage
key={modalState.images[modalState.index].uri}
uri={modalState.images[modalState.index].uri}
width={modalState.images[modalState.index].width}
height={modalState.images[modalState.index].height}
onClose={() => setModalState(null)}
onSwipeVertical={handleSwipeVertical}
/>
{modalState.images.length > 1 && (
<View style={styles.modalPagination}>
<Text style={styles.modalPaginationText}>
{modalState.index + 1} / {modalState.images.length}
</Text>
</View>
)}
</GestureHandlerRootView>
)}
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
pageWrapper: {
width: SCREEN_WIDTH,
},
detailContainer: {
flex: 1,
backgroundColor: '#000',
},
imageCentered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: SCREEN_WIDTH,
},
headerOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
paddingBottom: 8,
backgroundColor: 'rgba(0,0,0,0.3)',
},
headerBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
},
headerDate: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
flex: 1,
textAlign: 'center',
marginHorizontal: 8,
},
panel: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 20,
paddingTop: 12,
height: PANEL_HEIGHT,
},
panelHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#ddd',
alignSelf: 'center',
marginBottom: 12,
},
panelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
panelFileName: {
fontSize: 17,
fontWeight: '700',
color: '#333',
flex: 1,
marginRight: 8,
},
panelBody: {
flex: 1,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
},
metaText: {
fontSize: 14,
color: '#666',
},
tagsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginTop: 8,
},
tagsSection: {
marginTop: 8,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
color: '#333',
},
ocrSection: {
marginTop: 12,
},
ocrText: {
fontSize: 13,
color: '#555',
lineHeight: 18,
marginTop: 4,
},
actionsRow: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: '#f5f5f5',
},
actionBtnText: {
fontSize: 15,
fontWeight: '600',
color: '#1976D2',
},
cloudOnlyContainer: {
alignItems: 'center',
gap: 16,
},
downloadBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1976D2',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
gap: 8,
},
downloadBtnText: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
optionsOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
paddingBottom: 40,
},
optionsMenu: {
backgroundColor: '#fff',
borderRadius: 14,
marginHorizontal: 20,
overflow: 'hidden',
},
optionItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
paddingVertical: 16,
paddingHorizontal: 20,
},
optionText: {
fontSize: 16,
color: '#333',
},
optionDivider: {
height: 1,
backgroundColor: '#eee',
marginHorizontal: 20,
},
pagination: {
position: 'absolute',
bottom: 16,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 4,
},
paginationText: {
color: '#fff',
fontSize: 13,
},
modalPagination: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 4,
},
modalPaginationText: {
color: '#fff',
fontSize: 13,
},
});
-568
View File
@@ -1,568 +0,0 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
Dimensions,
Modal,
} from 'react-native';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useAddTags } from '../hooks/useFiles';
import { usePdfGeneration } from '../hooks/usePdfGeneration';
import { useUpload } from '../hooks/useUpload';
import { TagChip } from '../components/TagChip';
import { FileThumbnail } from '../components/FileThumbnail';
import { ZoomableImage } from '../components/ZoomableImage';
import { fileStore } from '../services/fileStore';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import type { SyncStatus, Variant } from '../types';
const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
const PADDING = 16;
const ITEM_GAP = 6;
const ITEM_SIZE = (SCREEN_WIDTH - PADDING * 2 - (NUM_COLUMNS - 1) * ITEM_GAP) / NUM_COLUMNS;
type FileEditRouteParams = {
FileEdit: { fileIds: string[] };
};
type PreviewFile = { uri: string; width: number; height: number };
interface FileEditItemProps {
fileId: string;
selected: boolean;
onSelect: (id: string) => void;
onPreview: (id: string) => void;
size: number;
}
const FileEditItem = React.memo(function FileEditItem({ fileId, selected, onSelect, onPreview, size }: FileEditItemProps) {
const record = fileStore.getByBackendId(fileId) ?? fileStore.getById(fileId);
const uri = record?.localUri ?? undefined;
const thumbnailUrl = record?.thumbnailUrl ?? undefined;
const mimeType = record?.mimeType ?? 'application/octet-stream';
const fileName = record?.name ?? fileId;
const syncStatus = (record?.syncStatus ?? 'cloud') as SyncStatus;
const isViewable = mimeType.startsWith('image/') || mimeType === 'application/pdf';
return (
<View style={{ width: size, height: size, marginBottom: ITEM_GAP, borderRadius: 6, overflow: 'hidden' }}>
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={0.7}
onPress={() => { if (isViewable) onPreview(fileId); else onSelect(fileId); }}
>
<FileThumbnail
uri={uri}
thumbnailUrl={thumbnailUrl}
mimeType={mimeType}
fileName={fileName}
size={size}
syncStatus={syncStatus}
/>
</TouchableOpacity>
{isViewable && (
<TouchableOpacity
style={styles.previewBtn}
onPress={() => onPreview(fileId)}
hitSlop={6}
>
<MaterialIcons name="visibility" size={16} color="#fff" />
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.selectBtn}
onPress={() => onSelect(fileId)}
hitSlop={8}
>
<View style={[styles.checkCircle, selected && styles.checkCircleSelected]}>
{selected && <MaterialIcons name="check" size={14} color="#fff" />}
</View>
</TouchableOpacity>
</View>
);
});
export function FileEditScreen() {
const route = useRoute<RouteProp<FileEditRouteParams, 'FileEdit'>>();
const navigation = useNavigation();
const { fileIds } = route.params;
const addTags = useAddTags();
const { generatePdf, generating, progress } = usePdfGeneration();
const upload = useUpload();
const [tagInput, setTagInput] = useState('');
const [pendingTags, setPendingTags] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [previewFiles, setPreviewFiles] = useState<PreviewFile[] | null>(null);
const [previewIndex, setPreviewIndex] = useState(0);
const [previewLoading, setPreviewLoading] = useState(false);
const hasSelection = selectedIds.size > 0;
const targetIds = hasSelection ? Array.from(selectedIds) : fileIds;
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) => {
if (prev.size === fileIds.length) return new Set();
return new Set(fileIds);
});
}, [fileIds]);
const openPreview = useCallback((files: PreviewFile[], index: number) => {
setPreviewFiles(files);
setPreviewIndex(index);
}, []);
const handlePreview = useCallback(async (fileId: string) => {
const record = fileStore.getByBackendId(fileId) ?? fileStore.getById(fileId);
if (record?.localUri) {
openPreview([{ uri: record.localUri, width: SCREEN_WIDTH, height: SCREEN_WIDTH }], 0);
return;
}
setPreviewLoading(true);
try {
const data = await apiClient.get<any>(`${ENDPOINTS.RESOURCES}/${fileId}`);
const url = data?.url;
if (url) {
const fullVariants: Variant[] = (data?.variants ?? [])
.filter((v: Variant) => v.variantType === 'thumbnail_full')
.sort((a: Variant, b: Variant) => a.pageNumber - b.pageNumber);
if (fullVariants.length > 0) {
const pages: PreviewFile[] = fullVariants.map((v) => ({
uri: v.url,
width: v.width,
height: v.height,
}));
openPreview(pages, 0);
} else {
openPreview([{ uri: url, width: SCREEN_WIDTH, height: SCREEN_WIDTH }], 0);
}
} else {
Alert.alert('Erreur', 'Impossible de charger l\'aperçu');
}
} catch {
Alert.alert('Erreur', 'Impossible de charger l\'aperçu');
} finally {
setPreviewLoading(false);
}
}, [openPreview]);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
setPreviewIndex((prev) => {
if (!previewFiles || previewFiles.length <= 1) return prev;
const next = direction === 'up'
? Math.min(prev + 1, previewFiles.length - 1)
: Math.max(prev - 1, 0);
return next;
});
}, [previewFiles]);
const handleAddTag = () => {
const tag = tagInput.trim().toLowerCase();
if (!tag || pendingTags.includes(tag)) return;
setPendingTags((prev) => [...prev, tag]);
setTagInput('');
};
const handleRemoveTag = (tag: string) => {
setPendingTags((prev) => prev.filter((t) => t !== tag));
};
const handleApplyTags = useCallback(async () => {
if (pendingTags.length === 0) return;
for (const fileId of targetIds) {
await addTags.mutateAsync({ fileId, tags: pendingTags });
}
Alert.alert('Succès', `${pendingTags.length} tag${pendingTags.length > 1 ? 's' : ''} ajouté${pendingTags.length > 1 ? 's' : ''}`);
setPendingTags([]);
}, [pendingTags, targetIds, addTags]);
const handleGeneratePdf = useCallback(async () => {
if (targetIds.length === 0) return;
setUploading(true);
try {
const results = await Promise.all(
targetIds.map(async (fileId) => {
const record = fileStore.getByBackendId(fileId) ?? fileStore.getById(fileId);
if (record?.localUri) return { uri: record.localUri };
const data = await apiClient.get<{ url: string }>(`${ENDPOINTS.RESOURCES}/${fileId}`);
return { uri: data?.url || '' };
})
);
const imageUris = results.filter((r) => r.uri);
if (imageUris.length === 0) {
Alert.alert('Erreur', 'Aucune image trouvée pour la génération du PDF');
setUploading(false);
return;
}
const pdfUri = await generatePdf(imageUris);
if (!pdfUri) {
Alert.alert('Erreur', 'Échec de la génération du PDF');
setUploading(false);
return;
}
Alert.alert(
'PDF généré',
'Voulez-vous uploader le fichier ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Uploader',
onPress: async () => {
try {
const pdfName = `document_${Date.now()}.pdf`;
const pdfUriClean = pdfUri.startsWith('file://') ? pdfUri : 'file://' + pdfUri;
await upload.mutateAsync([
{ uri: pdfUriClean, type: 'application/pdf', name: pdfName },
]);
Alert.alert('Succès', 'PDF uploadé avec succès', [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
} catch (e: any) {
const msg = e?.message || e?.toString() || 'Erreur inconnue';
Alert.alert('Erreur', `Échec de l'upload du PDF: ${msg}`);
}
},
},
]
);
} finally {
setUploading(false);
}
}, [targetIds, generatePdf, upload, navigation]);
const isLoading = generating || uploading || previewLoading;
const renderItem = useCallback(({ item }: { item: string }) => (
<FileEditItem
fileId={item}
selected={hasSelection ? selectedIds.has(item) : true}
onSelect={toggleSelection}
onPreview={handlePreview}
size={ITEM_SIZE}
/>
), [hasSelection, selectedIds, toggleSelection, handlePreview]);
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.headerRow}>
<View>
<Text style={styles.title}>Édition</Text>
<Text style={styles.subtitle}>
{hasSelection
? `${selectedIds.size} sélectionné${selectedIds.size > 1 ? 's' : ''} / ${fileIds.length}`
: `${fileIds.length} fichier${fileIds.length > 1 ? 's' : ''}`}
</Text>
</View>
<TouchableOpacity style={styles.selectAllBtn} onPress={toggleSelectAll}>
<Text style={styles.selectAllText}>
{hasSelection && selectedIds.size === fileIds.length ? 'Tout' : 'Tout'}
</Text>
</TouchableOpacity>
</View>
</View>
<FlatList
data={fileIds}
numColumns={NUM_COLUMNS}
keyExtractor={(item) => item}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
renderItem={renderItem}
/>
<View style={styles.tagSection}>
<Text style={styles.sectionTitle}>Tags</Text>
<View style={styles.tagRow}>
{pendingTags.map((tag) => (
<TouchableOpacity key={tag} style={styles.tagChip} onPress={() => handleRemoveTag(tag)}>
<TagChip name={tag} onRemove={() => handleRemoveTag(tag)} />
</TouchableOpacity>
))}
</View>
<View style={styles.tagInputRow}>
<TextInput
style={styles.tagInput}
placeholder="Ajouter un tag..."
value={tagInput}
onChangeText={setTagInput}
onSubmitEditing={handleAddTag}
returnKeyType="done"
/>
<TouchableOpacity style={styles.tagAddBtn} onPress={handleAddTag}>
<MaterialIcons name="add" size={22} color="#fff" />
</TouchableOpacity>
</View>
{pendingTags.length > 0 && (
<TouchableOpacity style={styles.applyTagsBtn} onPress={handleApplyTags} disabled={addTags.isPending}>
{addTags.isPending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.applyTagsText}>Appliquer les tags</Text>
)}
</TouchableOpacity>
)}
</View>
<View style={styles.footer}>
{(generating || uploading) && (
<View style={styles.progressRow}>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.progressText}>
{generating ? `Génération du PDF... ${progress}%` : 'Upload en cours...'}
</Text>
</View>
)}
{previewLoading && (
<View style={styles.progressRow}>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.progressText}>Chargement de l'aperçu...</Text>
</View>
)}
<TouchableOpacity
style={[styles.pdfBtn, isLoading && styles.pdfBtnDisabled]}
onPress={handleGeneratePdf}
disabled={isLoading}
>
{generating ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<MaterialIcons name="picture-as-pdf" size={20} color="#fff" />
)}
<Text style={styles.pdfBtnText}>Créer un PDF</Text>
</TouchableOpacity>
</View>
<Modal
visible={previewFiles !== null}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setPreviewFiles(null)}
>
{previewFiles && (
<GestureHandlerRootView style={{ flex: 1 }}>
<ZoomableImage
key={previewFiles[previewIndex].uri}
uri={previewFiles[previewIndex].uri}
width={previewFiles[previewIndex].width}
height={previewFiles[previewIndex].height}
onClose={() => setPreviewFiles(null)}
onSwipeVertical={handleSwipeVertical}
/>
{previewFiles.length > 1 && (
<View style={styles.modalPagination}>
<Text style={styles.modalPaginationText}>
{previewIndex + 1} / {previewFiles.length}
</Text>
</View>
)}
</GestureHandlerRootView>
)}
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
header: {
padding: PADDING,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#666',
},
selectAllBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#E3F2FD',
},
selectAllText: {
fontSize: 13,
fontWeight: '600',
color: '#1976D2',
},
grid: {
padding: PADDING,
},
gridRow: {
gap: ITEM_GAP,
},
tagSection: {
padding: PADDING,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginBottom: 8,
},
tagChip: {
marginRight: 2,
},
tagInputRow: {
flexDirection: 'row',
gap: 8,
},
tagInput: {
flex: 1,
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
padding: 10,
fontSize: 14,
},
tagAddBtn: {
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
},
applyTagsBtn: {
marginTop: 10,
backgroundColor: '#4CAF50',
paddingVertical: 10,
borderRadius: 8,
alignItems: 'center',
},
applyTagsText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
footer: {
padding: PADDING,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 8,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
progressText: {
fontSize: 14,
color: '#666',
},
pdfBtn: {
flexDirection: 'row',
backgroundColor: '#1976D2',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
pdfBtnDisabled: {
opacity: 0.6,
},
pdfBtnText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
previewBtn: {
position: 'absolute',
top: 4,
right: 28,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
selectBtn: {
position: 'absolute',
bottom: 4,
right: 4,
padding: 4,
},
checkCircle: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: '#fff',
backgroundColor: 'rgba(0,0,0,0.3)',
justifyContent: 'center',
alignItems: 'center',
},
checkCircleSelected: {
backgroundColor: '#1976D2',
borderColor: '#1976D2',
},
modalPagination: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 4,
},
modalPaginationText: {
color: '#fff',
fontSize: 13,
},
});
-476
View File
@@ -1,476 +0,0 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import { View, FlatList, StyleSheet, TouchableOpacity, Text, Modal, TextInput } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
useDeleteFile, useFiles, useFreeLocalSpace,
useAddTags, useMoveResources, useFolders, useCreateFolder,
} from '../hooks/useFiles';
import { SelectionPanel } from '../components/SelectionPanel';
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
import { UnifiedFileItem } from '../types';
import { isFolder } from '../types';
import { FileCard } from '../components/FileCard';
import { fileStore } from '../services/fileStore';
import { downloadRegistry } from '../services/downloadRegistry';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { useQueryClient } from '@tanstack/react-query';
import { deleteAsync } from 'expo-file-system/legacy';
const PAGE_SIZE = 100;
type RootStackParamList = {
Folder: { folderId: string; folderName: string };
FileDetail: { fileIds: string[]; initialIndex: number };
FileEdit: { fileIds: string[] };
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type FolderRouteProp = RouteProp<RootStackParamList, 'Folder'>;
export function FolderScreen() {
const route = useRoute<FolderRouteProp>();
const navigation = useNavigation<NavigationProp>();
const { folderId, folderName } = route.params;
const [page, setPage] = useState(1);
const { data, isLoading, isFetching } = useFiles(folderId, page, PAGE_SIZE);
const deleteFile = useDeleteFile();
const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const addTags = useAddTags();
const moveFiles = useMoveResources();
const createFolder = useCreateFolder();
const { data: foldersData } = useFolders();
const insets = useSafeAreaInsets();
const loadMore = useCallback(() => {
if (isFetching) return;
const total = data?.meta?.total ?? 0;
const loaded = data?.data?.length ?? 0;
if (loaded < total) {
setPage((p) => p + 1);
}
}, [isFetching, data?.meta?.total, data?.data?.length]);
const hasMore = (data?.data?.length ?? 0) > 0 && (data?.data?.length ?? 0) < (data?.meta?.total ?? 0);
const [tagModalVisible, setTagModalVisible] = useState(false);
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
const [tagInput, setTagInput] = useState('');
const [moveModalVisible, setMoveModalVisible] = useState(false);
const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null);
const selectionMode = selectedIds.size > 0;
useEffect(() => {
navigation.setOptions({ title: folderName });
}, [navigation, folderName]);
const files = useMemo(() => {
return data?.data ?? [];
}, [data]);
const fileIdToIndex = useMemo(() => {
const map = new Map<string, number>();
files.forEach((f, i) => map.set(f.id, i));
return map;
}, [files]);
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
}, []);
const handleDelete = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
const hasSynced = ids.some((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`;
const options: ConfirmOption[] = [];
if (hasSynced) {
options.push({
label: 'Du device uniquement',
onPress: async () => {
const syncedIds = ids.filter((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
if (syncedIds.length > 0) {
await freeLocalSpace.mutateAsync(syncedIds);
}
setSelectedIds(new Set());
},
});
}
options.push({
label: 'Du device + serveur',
destructive: true,
onPress: async () => {
for (const id of ids) {
const f = files.find((fi) => fi.id === id);
if (f?.backendResourceId) {
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${f.backendResourceId}`);
fileStore.deleteByBackendId(f.backendResourceId);
} else {
fileStore.deleteById(id);
}
if (f?.localUri) {
try { await deleteAsync(f.localUri, { idempotent: true }); } catch {}
}
downloadRegistry.remove(id);
}
await queryClient.invalidateQueries({ queryKey: ['resources'] });
setSelectedIds(new Set());
},
});
options.push({ label: 'Annuler' });
setConfirmDeleteState({ message: `Supprimer ${label} ?`, options });
}, [selectedIds, files, deleteFile, freeLocalSpace, queryClient]);
const handleEdit = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
navigation.navigate('FileEdit', { fileIds: ids });
setSelectedIds(new Set());
}, [selectedIds, navigation]);
const openTagModal = useCallback((mode: 'tag' | 'folder') => {
setTagModalMode(mode);
setTagInput('');
setTagModalVisible(true);
}, []);
const handleAddTag = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
for (const id of ids) {
await addTags.mutateAsync({ fileId: id, tags: [name] });
}
setTagModalVisible(false);
setSelectedIds(new Set());
}, [tagInput, selectedIds, addTags]);
const handleCreateFolderFromModal = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
try {
const newFolder = await createFolder.mutateAsync({ name, parentResourceId: folderId });
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: newFolder.id });
setTagModalVisible(false);
setSelectedIds(new Set());
navigation.navigate('Folder', { folderId: newFolder.id, folderName: newFolder.name });
} catch {}
}, [tagInput, selectedIds, createFolder, moveFiles, navigation, folderId]);
const handleMove = useCallback(async (folderId: string | null) => {
const ids = Array.from(selectedIds);
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: folderId });
setMoveModalVisible(false);
setSelectedIds(new Set());
}, [selectedIds, moveFiles]);
const handleItemPress = useCallback((file: UnifiedFileItem) => {
if (selectionMode) {
toggleSelection(file.id);
} else if (isFolder(file)) {
navigation.push('Folder', { folderId: file.id, folderName: file.name });
} else {
navigation.navigate('FileDetail', {
fileIds: files.map((f) => f.id),
initialIndex: fileIdToIndex.get(file.id) ?? 0,
});
}
}, [selectionMode, toggleSelection, navigation, files, fileIdToIndex]);
const handleItemLongPress = useCallback((file: UnifiedFileItem) => {
if (!selectionMode) toggleSelection(file.id);
}, [selectionMode, toggleSelection]);
if (isLoading) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Chargement...</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={files}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={
<View style={styles.empty}>
<MaterialIcons name="folder-open" size={48} color="#ccc" />
<Text style={styles.emptyText}>Dossier vide</Text>
</View>
}
ListFooterComponent={
isFetching ? (
<View style={styles.footer}>
<Text style={styles.footerText}>Chargement...</Text>
</View>
) : hasMore ? (
<TouchableOpacity style={styles.footer} onPress={loadMore}>
<Text style={styles.footerLink}>Charger plus</Text>
</TouchableOpacity>
) : null
}
renderItem={({ item: file }) => (
<FileCard
file={file}
selected={selectedIds.has(file.id)}
onPress={handleItemPress}
onLongPress={handleItemLongPress}
/>
)}
/>
{selectionMode && (
<SelectionPanel
selectedCount={selectedIds.size}
onClose={clearSelection}
onDelete={handleDelete}
onEdit={handleEdit}
onTags={() => openTagModal('tag')}
onFolder={() => openTagModal('folder')}
onMove={() => setMoveModalVisible(true)}
insetsBottom={insets.bottom}
/>
)}
<Modal visible={tagModalVisible} transparent animationType="fade" onRequestClose={() => setTagModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setTagModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>
{tagModalMode === 'folder' ? 'Créer un dossier' : 'Ajouter un tag'}
</Text>
<TextInput
style={styles.modalInput}
placeholder={tagModalMode === 'folder' ? 'Nom du dossier...' : 'Nom du tag...'}
placeholderTextColor="#999"
value={tagInput}
onChangeText={setTagInput}
autoFocus
returnKeyType="done"
onSubmitEditing={tagModalMode === 'folder' ? handleCreateFolderFromModal : handleAddTag}
/>
<View style={styles.modalActions}>
<TouchableOpacity style={styles.modalCancelBtn} onPress={() => setTagModalVisible(false)}>
<Text style={styles.modalCancelText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalConfirmBtn, !tagInput.trim() && styles.modalConfirmDisabled]}
onPress={tagModalMode === 'folder' ? handleCreateFolderFromModal : handleAddTag}
disabled={!tagInput.trim()}
>
<Text style={styles.modalConfirmText}>{tagModalMode === 'folder' ? 'Créer' : 'Ajouter'}</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal visible={moveModalVisible} transparent animationType="fade" onRequestClose={() => setMoveModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setMoveModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>Déplacer vers...</Text>
<TouchableOpacity
style={styles.folderOption}
onPress={() => handleMove(null)}
>
<MaterialIcons name="home" size={20} color="#666" />
<Text style={styles.folderOptionText}>Racine</Text>
</TouchableOpacity>
{(foldersData ?? []).map((folder) => (
<TouchableOpacity
key={folder.id}
style={styles.folderOption}
onPress={() => handleMove(folder.id)}
>
<MaterialIcons name="folder" size={20} color="#F57C00" />
<Text style={styles.folderOptionText}>{folder.name}</Text>
</TouchableOpacity>
))}
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<ConfirmModal
visible={confirmDeleteState !== null}
title="Supprimer"
message={confirmDeleteState?.message}
options={confirmDeleteState?.options ?? []}
onClose={() => setConfirmDeleteState(null)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
infoText: {
fontSize: 16,
color: '#666',
},
list: {
padding: 15,
paddingBottom: 80,
},
empty: {
paddingVertical: 60,
alignItems: 'center',
gap: 12,
},
emptyText: {
fontSize: 16,
color: '#999',
},
selectionBar: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingHorizontal: 16,
paddingVertical: 12,
},
selectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
cancelBtn: {
padding: 4,
},
selectionCount: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
deleteBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1.5,
borderColor: '#F44336',
gap: 6,
},
deleteText: {
fontSize: 15,
fontWeight: '600',
color: '#F44336',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
width: '80%',
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#333',
marginBottom: 16,
},
modalInput: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#333',
marginBottom: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalCancelBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
},
modalCancelText: {
fontSize: 15,
color: '#666',
},
modalConfirmBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#1976D2',
borderRadius: 8,
},
modalConfirmDisabled: {
backgroundColor: '#ccc',
},
modalConfirmText: {
fontSize: 15,
color: '#fff',
fontWeight: '600',
},
folderOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 8,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
folderOptionText: {
fontSize: 16,
color: '#333',
},
footer: {
paddingVertical: 20,
alignItems: 'center',
},
footerText: {
fontSize: 14,
color: '#999',
},
footerLink: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
});
-902
View File
@@ -1,902 +0,0 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { View, FlatList, StyleSheet, TouchableOpacity, Text, KeyboardAvoidingView, Platform, Keyboard, Modal, TextInput, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons';
import { useAddTags, useMoveResources, useFolders, useFiles, useFreeLocalSpace, useCreateFolder } from '../hooks/useFiles';
import { SelectionPanel } from '../components/SelectionPanel';
import { UnifiedFileItem, isFolder } from '../types';
import { SearchBar, SearchFilters, SortState } from '../components/SearchBar';
import { SortChips } from '../components/SortChips';
import { FileCard } from '../components/FileCard';
import { SettingsModal } from '../components/SettingsModal';
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
import { UploadModal } from '../components/UploadModal';
import { SyncStatusIcon } from '../components/SyncStatusIcon';
import { NetworkStatusBar } from '../components/NetworkStatusBar';
import { useSyncQueue } from '../hooks/useSyncQueue';
import { useUploadQueue } from '../hooks/useUploadQueue';
import { useAutoSync } from '../hooks/useAutoSync';
import { safDirectory, SyncMode, SyncGlobalMode } from '../services/safDirectory';
import { fileStore } from '../services/fileStore';
import { downloadRegistry } from '../services/downloadRegistry';
import { useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { useLocalFiles } from '../hooks/useLocalFiles';
import { deleteAsync } from 'expo-file-system/legacy';
import { useDebounce } from '../hooks/useDebounce';
const PAGE_SIZE = 100;
type RootStackParamList = {
Home: undefined;
Upload: undefined;
Scan: undefined;
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
FileEdit: { fileIds: string[] };
Folder: { folderId: string; folderName: string };
SyncDetail: undefined;
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
function parseBackendDate(dateStr: string): Date | null {
if (!dateStr) return null;
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/);
if (!match) return new Date(dateStr);
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]));
}
function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean {
if (!query) return true;
const q = query.toLowerCase();
if (filters.name && file.name.toLowerCase().includes(q)) return true;
if (filters.ocrText && file.ocrText?.toLowerCase().includes(q)) return true;
if (!filters.name && !filters.ocrText) {
if (file.name.toLowerCase().includes(q)) return true;
if (file.ocrText?.toLowerCase().includes(q)) return true;
}
return false;
}
function compareBySort(a: UnifiedFileItem, b: UnifiedFileItem, sort: SortState): number {
let cmp = 0;
if (sort.key === 'name') {
cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
} else if (sort.key === 'size') {
cmp = (a.size ?? 0) - (b.size ?? 0);
} else {
const da = parseBackendDate(a.createdAt);
const db = parseBackendDate(b.createdAt);
cmp = (da?.getTime() ?? 0) - (db?.getTime() ?? 0);
}
return sort.direction === 'asc' ? cmp : -cmp;
}
export function HomeScreen() {
const navigation = useNavigation<NavigationProp>();
const insets = useSafeAreaInsets();
const [page, setPage] = useState(1);
const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE);
const { pickAndScanRecursive, folders, refreshFolders, discovered } = useLocalFiles();
const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 250);
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
const toggleFilter = useCallback((key: keyof SearchFilters) => {
setFilters((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const [sort, setSort] = useState<SortState>({ key: 'date', direction: 'desc' });
const listRef = useRef<FlatList<UnifiedFileItem>>(null);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [tagModalVisible, setTagModalVisible] = useState(false);
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
const [tagInput, setTagInput] = useState('');
const addTags = useAddTags();
const moveFiles = useMoveResources();
const createFolder = useCreateFolder();
const { data: foldersData } = useFolders();
const [moveModalVisible, setMoveModalVisible] = useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [filterModalVisible, setFilterModalVisible] = useState(false);
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [globalSyncMode, setGlobalSyncMode] = useState<SyncGlobalMode>(() => safDirectory.getGlobalSyncMode());
const [globalSyncCellular, setGlobalSyncCellular] = useState(() => safDirectory.getGlobalSyncCellular());
const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null);
const [removeFolderConfirmId, setRemoveFolderConfirmId] = useState<string | null>(null);
const { pendingCount, isSyncing } = useSyncQueue();
const { tasks: uploadTasks } = useUploadQueue();
useAutoSync();
const loadingMoreRef = useRef(false);
const listLayoutHeightRef = useRef(0);
const listContentHeightRef = useRef(0);
const loadMore = useCallback(() => {
if (loadingMoreRef.current) return;
const total = data?.meta?.total ?? 0;
const loaded = data?.data?.length ?? 0;
if (loaded < total) {
loadingMoreRef.current = true;
setPage((p) => p + 1);
}
}, [data?.meta?.total, data?.data?.length]);
useEffect(() => {
if (!isFetching) {
loadingMoreRef.current = false;
}
}, [isFetching]);
const handleEndReached = useCallback(() => {
if (listLayoutHeightRef.current > 0 &&
listContentHeightRef.current > 0 &&
listContentHeightRef.current <= listLayoutHeightRef.current) {
return;
}
loadMore();
}, [loadMore]);
const totalFiles = data?.meta?.total ?? 0;
const loadedFiles = data?.data?.length ?? 0;
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
const isFiltering = !!debouncedSearch.trim();
useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardOpen(false));
return () => { show.remove(); hide.remove(); };
}, []);
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<NetworkStatusBar />
<SyncStatusIcon
isSyncing={isSyncing}
pendingCount={pendingCount}
isUploading={uploadTasks.some(t => t.status === 'uploading')}
uploadPendingCount={uploadTasks.filter(t => t.status === 'pending' || t.status === 'uploading').length}
onPress={() => navigation.navigate('SyncDetail')}
/>
<TouchableOpacity onPress={() => setUploadModalVisible(true)} style={{ padding: 8 }}>
<MaterialIcons name="add-circle-outline" size={22} color="#1976D2" />
</TouchableOpacity>
<TouchableOpacity onPress={() => setSettingsModalVisible(true)} style={{ marginRight: 4, padding: 8 }}>
<MaterialIcons name="settings" size={22} color="#666" />
</TouchableOpacity>
</View>
),
});
}, [navigation, pendingCount, isSyncing, uploadTasks]);
const selectionMode = selectedIds.size > 0;
const files = data?.data ?? [];
const filteredFiles = useMemo(
() => {
const search = debouncedSearch.trim();
if ( search ) {
return files.filter((f) => matchesQuery(f, debouncedSearch, filters));
}
return files;
},
[files, debouncedSearch, filters]
);
const uploadGhostItems = useMemo(() => {
return uploadTasks
.filter((t) => t.status === 'pending' || t.status === 'uploading')
.map((t) => ({
id: t.id,
name: t.file.name,
mimeType: t.file.type,
size: 0,
createdAt: new Date(t.createdAt).toISOString(),
source: 'local' as const,
syncStatus: 'local' as const,
localUri: t.file.uri,
tags: [],
isFolder: false,
isDeviceFile: false,
isUploading: true,
uploadProgress: t.progress,
uploadStatus: t.status,
}));
}, [uploadTasks]);
const sortedFiles = useMemo(() => {
const uploadedExistingIds = new Set(
filteredFiles.map((f) => f.localUri).filter(Boolean)
);
const ghosts = uploadGhostItems.filter(
(g) => g.localUri && !uploadedExistingIds.has(g.localUri)
);
const sorted = [...filteredFiles].sort((a, b) => compareBySort(a, b, sort));
return [...ghosts, ...sorted];
}, [filteredFiles, uploadGhostItems, sort]);
const displayedCount = sortedFiles.length;
const fileIdToIndex = useMemo(() => {
const map = new Map<string, number>();
sortedFiles.forEach((f, i) => map.set(f.id, i));
return map;
}, [sortedFiles]);
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
}, []);
const handleDelete = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
const hasSynced = ids.some((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`;
const options: ConfirmOption[] = [];
if (hasSynced) {
options.push({
label: 'Du device uniquement',
onPress: async () => {
const syncedIds = ids.filter((id) => {
const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
if (syncedIds.length > 0) {
await freeLocalSpace.mutateAsync(syncedIds);
}
setSelectedIds(new Set());
},
});
}
options.push({
label: 'Du device + serveur',
destructive: true,
onPress: async () => {
for (const id of ids) {
const f = files.find((fi) => fi.id === id);
if (f?.backendResourceId) {
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${f.backendResourceId}`);
fileStore.deleteByBackendId(f.backendResourceId);
} else {
fileStore.deleteById(id);
}
if (f?.localUri) {
try { await deleteAsync(f.localUri, { idempotent: true }); } catch {}
}
downloadRegistry.remove(id);
}
await queryClient.invalidateQueries({ queryKey: ['resources'] });
setSelectedIds(new Set());
},
});
options.push({ label: 'Annuler' });
setConfirmDeleteState({ message: `Supprimer ${label} ?`, options });
}, [selectedIds, files, freeLocalSpace, queryClient]);
const handleEdit = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
navigation.navigate('FileEdit', { fileIds: ids });
setSelectedIds(new Set());
}, [selectedIds, navigation]);
const openTagModal = useCallback((mode: 'tag' | 'folder') => {
setTagModalMode(mode);
setTagInput('');
setTagModalVisible(true);
}, []);
const handleAddTag = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
for (const id of ids) {
await addTags.mutateAsync({ fileId: id, tags: [name] });
}
setTagModalVisible(false);
setSelectedIds(new Set());
}, [tagInput, selectedIds, addTags]);
const handleCreateFolderFromModal = useCallback(async () => {
const name = tagInput.trim();
if (!name) return;
const ids = Array.from(selectedIds);
try {
const newFolder = await createFolder.mutateAsync({ name });
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: newFolder.id });
setTagModalVisible(false);
setSelectedIds(new Set());
navigation.navigate('Folder', { folderId: newFolder.id, folderName: newFolder.name });
} catch {}
}, [tagInput, selectedIds, createFolder, moveFiles, navigation]);
const handleMove = useCallback(async (folderId: string | null) => {
const ids = Array.from(selectedIds);
await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: folderId });
setMoveModalVisible(false);
setSelectedIds(new Set());
}, [selectedIds, moveFiles]);
const handleToggleFolderVisibility = useCallback((folderId: string) => {
safDirectory.toggleVisibility(folderId);
refreshFolders();
}, [refreshFolders]);
const handleRemoveFolder = useCallback((folderId: string) => {
setRemoveFolderConfirmId(folderId);
}, []);
const handleRemoveFolderConfirm = useCallback(() => {
if (removeFolderConfirmId) {
safDirectory.removeFolder(removeFolderConfirmId);
refreshFolders();
}
setRemoveFolderConfirmId(null);
}, [removeFolderConfirmId, refreshFolders]);
const handleAddFolderRecursive = useCallback(async () => {
setSettingsModalVisible(false);
await pickAndScanRecursive();
}, [pickAndScanRecursive]);
const handleUpdateSyncMode = useCallback((folderId: string, mode: SyncMode) => {
safDirectory.updateSyncMode(folderId, mode);
refreshFolders();
}, [refreshFolders]);
const handleUpdateSyncCellular = useCallback((folderId: string, enabled: boolean) => {
safDirectory.updateSyncCellular(folderId, enabled);
refreshFolders();
}, [refreshFolders]);
const handleSetGlobalSyncMode = useCallback((mode: SyncGlobalMode) => {
safDirectory.setGlobalSyncMode(mode);
setGlobalSyncMode(mode);
}, []);
const handleSetGlobalSyncCellular = useCallback((enabled: boolean) => {
safDirectory.setGlobalSyncCellular(enabled);
setGlobalSyncCellular(enabled);
}, []);
const handleItemPress = useCallback((file: UnifiedFileItem) => {
if (selectionMode) {
toggleSelection(file.id);
} else if (isFolder(file)) {
navigation.navigate('Folder', { folderId: file.id, folderName: file.name });
} else {
const deviceFilesMap: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> = {};
for (const f of sortedFiles) {
if (f.isDeviceFile && f.localUri) {
deviceFilesMap[f.id] = { localUri: f.localUri, name: f.name, mimeType: f.mimeType, createdAt: f.createdAt };
}
}
navigation.navigate('FileDetail', {
fileIds: sortedFiles.map((f) => f.id),
initialIndex: fileIdToIndex.get(file.id) ?? 0,
deviceFiles: Object.keys(deviceFilesMap).length > 0 ? deviceFilesMap : undefined,
});
}
}, [selectionMode, toggleSelection, navigation, sortedFiles, fileIdToIndex]);
const handleItemLongPress = useCallback((file: UnifiedFileItem) => {
if (!selectionMode) {
toggleSelection(file.id);
}
}, [selectionMode, toggleSelection]);
const renderItem = useCallback(({ item }: { item: UnifiedFileItem }) => (
<FileCard
file={item}
selected={selectedIds.has(item.id)}
onPress={handleItemPress}
onLongPress={handleItemLongPress}
/>
), [selectedIds, handleItemPress, handleItemLongPress]);
if (isLoading) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Chargement...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Erreur de chargement</Text>
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 96 : 0}
>
{files.length === 0 && !searchQuery && (
<View style={styles.folderScanBanner}>
{folders.length > 0 ? (
<>
<MaterialIcons name="folder" size={24} color="#F57C00" />
<Text style={styles.folderScanText}>
{folders.length} dossier{folders.length > 1 ? 's' : ''} scanné{folders.length > 1 ? 's' : ''}
</Text>
<TouchableOpacity style={styles.permissionBtn} onPress={pickAndScanRecursive}>
<Text style={styles.permissionBtnText}>Tout scanner</Text>
</TouchableOpacity>
</>
) : (
<>
<MaterialIcons name="folder-open" size={24} color="#F57C00" />
<Text style={styles.folderScanText}>
Scanner un dossier de votre appareil
</Text>
<TouchableOpacity style={styles.permissionBtn} onPress={pickAndScanRecursive}>
<Text style={styles.permissionBtnText}>Choisir</Text>
</TouchableOpacity>
</>
)}
</View>
)}
<SortChips
sort={sort}
onSortChange={setSort}
/>
<View style={styles.listWrapper}>
<FlatList
ref={listRef}
data={sortedFiles}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onLayout={(e) => {
listLayoutHeightRef.current = e.nativeEvent.layout.height;
}}
onContentSizeChange={(w, h) => {
listContentHeightRef.current = h;
}}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
onScrollToIndexFailed={({ index, averageItemLength }) => {
listRef.current?.scrollToOffset({
offset: Math.max(0, averageItemLength * index),
animated: true,
});
setTimeout(() => {
listRef.current?.scrollToIndex({ index, animated: true, viewPosition: 0 });
}, 150);
}}
ListFooterComponent={
hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore} disabled={isFetching}>
{isFetching ? (
<ActivityIndicator size="small" color="#1976D2" />
) : (
<Text style={styles.loadMoreText}>
Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`})
</Text>
)}
</TouchableOpacity>
) : displayedCount > 0 ? (
<Text style={styles.loadedAllText}>{displayedCount} fichier{displayedCount > 1 ? 's' : ''}</Text>
) : null
}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>
{debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'}
</Text>
</View>
}
renderItem={renderItem}
/>
</View>
{!selectionMode && (
<SearchBar
query={searchQuery}
onQueryChange={setSearchQuery}
onClear={() => setSearchQuery('')}
filters={filters}
onFiltersChange={setFilters}
sort={sort}
onSettingsPress={() => setFilterModalVisible(true)}
bottomPadding={keyboardOpen ? insets.bottom+8 : 0}
/>
)}
{selectionMode ? (
<SelectionPanel
selectedCount={selectedIds.size}
onClose={clearSelection}
onDelete={handleDelete}
onEdit={handleEdit}
onTags={() => openTagModal('tag')}
onFolder={() => openTagModal('folder')}
onMove={() => setMoveModalVisible(true)}
insetsBottom={insets.bottom}
/>
) : (
<View style={styles.bottomNav}>
<TouchableOpacity style={styles.navButton} onPress={() => {}}>
<MaterialIcons name="home" size={24} color="#1976D2" />
<Text style={styles.navText}>Accueil</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.navButton}
onPress={() => navigation.navigate('Scan')}
>
<MaterialIcons name="document-scanner" size={24} color="#1976D2" />
<Text style={styles.navText}>Scan</Text>
</TouchableOpacity>
</View>
)}
<Modal visible={tagModalVisible} transparent animationType="fade" onRequestClose={() => setTagModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setTagModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>
{tagModalMode === 'folder' ? 'Créer un dossier' : 'Ajouter un tag'}
</Text>
<TextInput
style={styles.modalInput}
placeholder={tagModalMode === 'folder' ? 'Nom du dossier...' : 'Nom du tag...'}
placeholderTextColor="#999"
value={tagInput}
onChangeText={setTagInput}
autoFocus
returnKeyType="done"
onSubmitEditing={handleAddTag}
/>
<View style={styles.modalActions}>
<TouchableOpacity style={styles.modalCancelBtn} onPress={() => setTagModalVisible(false)}>
<Text style={styles.modalCancelText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalConfirmBtn, !tagInput.trim() && styles.modalConfirmDisabled]}
onPress={tagModalMode === 'folder' ? handleCreateFolderFromModal : handleAddTag}
disabled={!tagInput.trim()}
>
<Text style={styles.modalConfirmText}>{tagModalMode === 'folder' ? 'Créer' : 'Ajouter'}</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal visible={moveModalVisible} transparent animationType="fade" onRequestClose={() => setMoveModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setMoveModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>Déplacer vers...</Text>
<TouchableOpacity
style={styles.folderOption}
onPress={() => handleMove(null)}
>
<MaterialIcons name="home" size={20} color="#666" />
<Text style={styles.folderOptionText}>Racine</Text>
</TouchableOpacity>
{(foldersData ?? []).map((folder) => (
<TouchableOpacity
key={folder.id}
style={styles.folderOption}
onPress={() => handleMove(folder.id)}
>
<MaterialIcons name="folder" size={20} color="#F57C00" />
<Text style={styles.folderOptionText}>{folder.name}</Text>
</TouchableOpacity>
))}
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal visible={filterModalVisible} transparent animationType="fade" onRequestClose={() => setFilterModalVisible(false)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setFilterModalVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
<Text style={styles.modalTitle}>Rechercher dans</Text>
<TouchableOpacity
style={styles.folderOption}
onPress={() => toggleFilter('name')}
>
<MaterialIcons name="drive-file-rename-outline" size={20} color="#666" />
<Text style={styles.folderOptionText}>Nom</Text>
<View style={styles.filterCheck}>
{filters.name && <MaterialIcons name="check" size={18} color="#1976D2" />}
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.folderOption}
onPress={() => toggleFilter('ocrText')}
>
<MaterialIcons name="document-scanner" size={20} color="#666" />
<Text style={styles.folderOptionText}>Texte OCR</Text>
<View style={styles.filterCheck}>
{filters.ocrText && <MaterialIcons name="check" size={18} color="#1976D2" />}
</View>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<UploadModal
visible={uploadModalVisible}
onClose={() => setUploadModalVisible(false)}
/>
<SettingsModal
visible={settingsModalVisible}
onClose={() => setSettingsModalVisible(false)}
folders={folders}
onToggleVisibility={handleToggleFolderVisibility}
onRemoveFolder={handleRemoveFolder}
onAddFolderRecursive={handleAddFolderRecursive}
onUpdateSyncMode={handleUpdateSyncMode}
onUpdateSyncCellular={handleUpdateSyncCellular}
globalSyncMode={globalSyncMode}
onSetGlobalSyncMode={handleSetGlobalSyncMode}
globalSyncCellular={globalSyncCellular}
onSetGlobalSyncCellular={handleSetGlobalSyncCellular}
/>
<ConfirmModal
visible={confirmDeleteState !== null}
title="Supprimer"
message={confirmDeleteState?.message}
options={confirmDeleteState?.options ?? []}
onClose={() => setConfirmDeleteState(null)}
/>
<ConfirmModal
visible={removeFolderConfirmId !== null}
title="Supprimer le dossier"
message="Le dossier sera retiré de la liste. Les fichiers resteront sur votre appareil."
options={[
{ label: 'Annuler' },
{ label: 'Supprimer', destructive: true, onPress: handleRemoveFolderConfirm },
]}
onClose={() => setRemoveFolderConfirmId(null)}
/>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
loadMoreBtn: {
alignItems: 'center',
paddingVertical: 16,
},
loadMoreText: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
loadedAllText: {
textAlign: 'center',
fontSize: 13,
color: '#999',
paddingVertical: 12,
},
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
permissionBtn: {
backgroundColor: '#1976D2',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
permissionBtnText: {
fontSize: 14,
color: '#fff',
fontWeight: '600',
},
folderScanBanner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF3E0',
paddingHorizontal: 16,
paddingVertical: 14,
gap: 10,
},
folderScanText: {
flex: 1,
fontSize: 14,
color: '#333',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
infoText: {
fontSize: 16,
color: '#666',
},
listWrapper: {
flex: 1,
},
list: {
paddingHorizontal: 15,
paddingTop: 10,
paddingBottom: 80,
},
empty: {
paddingVertical: 60,
alignItems: 'center',
},
emptyText: {
fontSize: 16,
color: '#999',
},
bottomNav: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: 12,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
navButton: {
alignItems: 'center',
padding: 8,
gap: 4,
},
navText: {
fontSize: 16,
color: '#1976D2',
},
selectionBar: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingHorizontal: 16,
paddingTop: 12,
},
selectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
cancelBtn: {
padding: 4,
},
selectionCount: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
selectAllBtn: {
paddingHorizontal: 8,
paddingVertical: 4,
},
selectAllText: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
selectionActions: {
flexDirection: 'row',
gap: 8,
paddingVertical: 4,
},
selectionChip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
gap: 6,
},
chipText: {
fontSize: 13,
fontWeight: '600',
},
deleteChip: { backgroundColor: '#FFEBEE' },
editChip: { backgroundColor: '#E3F2FD' },
tagChip: { backgroundColor: '#F3E5F5' },
folderChip: { backgroundColor: '#FFF3E0' },
moveChip: { backgroundColor: '#E0F2F1' },
folderOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 8,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
folderOptionText: {
fontSize: 16,
color: '#333',
flex: 1,
},
filterCheck: {
width: 24,
alignItems: 'flex-end',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
width: '80%',
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#333',
marginBottom: 16,
},
modalInput: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#333',
marginBottom: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalCancelBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
},
modalCancelText: {
fontSize: 15,
color: '#666',
},
modalConfirmBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#1976D2',
borderRadius: 8,
},
modalConfirmDisabled: {
backgroundColor: '#ccc',
},
modalConfirmText: {
fontSize: 15,
color: '#fff',
fontWeight: '600',
},
});
-143
View File
@@ -1,143 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useAuth } from '../contexts/AuthContext';
export function LoginScreen({ navigation }: any) {
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleLogin() {
if (!username.trim() || !password) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
setLoading(true);
try {
await login(username.trim(), password);
} catch (error: any) {
Alert.alert('Erreur', error.message || 'Connexion échouée');
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.inner}>
<Text style={styles.title}>Dot.</Text>
<Text style={styles.subtitle}>Connectez-vous à votre compte</Text>
<TextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor="#999"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
secureTextEntry
editable={!loading}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Se connecter</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.navigate('Register')}
disabled={loading}
>
<Text style={styles.linkText}>Pas de compte ? S'inscrire</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
inner: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 32,
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 32,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 16,
},
button: {
backgroundColor: '#000',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#000',
fontSize: 14,
},
});
-297
View File
@@ -1,297 +0,0 @@
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
} from 'react-native';
import { useNavigation, CommonActions } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION } from '../config/onboarding';
import { onboardingStorage } from '../services/onboardingStorage';
import { safDirectory, StoredFolder } from '../services/safDirectory';
import * as FileSystem from 'expo-file-system/legacy';
function SelectFoldersStep({
selectedFolders,
onAddFolder,
onRemoveFolder,
}: {
selectedFolders: StoredFolder[];
onAddFolder: () => void;
onRemoveFolder: (folder: StoredFolder) => void;
}) {
return (
<View style={styles.folderStepContent}>
<View style={styles.iconContainer}>
<MaterialIcons name="create-new-folder" size={64} color="#1976D2" />
</View>
<Text style={styles.title}>Ajoutez vos dossiers</Text>
<Text style={styles.description}>
Sélectionnez les dossiers que vous souhaitez synchroniser avec Dot.
</Text>
{selectedFolders.length > 0 && (
<View style={styles.folderList}>
{selectedFolders.map((f) => (
<View key={f.id} style={styles.selectedFolderRow}>
<MaterialIcons name="folder" size={20} color="#F57C00" />
<Text style={styles.selectedFolderName} numberOfLines={1}>
{f.name}
</Text>
<TouchableOpacity
onPress={() => onRemoveFolder(f)}
style={styles.removeBtn}
>
<MaterialIcons name="close" size={18} color="#E53935" />
</TouchableOpacity>
</View>
))}
</View>
)}
<TouchableOpacity style={styles.actionBtn} onPress={onAddFolder}>
<MaterialIcons name="add" size={20} color="#fff" />
<Text style={styles.actionBtnText}>Ajouter un dossier</Text>
</TouchableOpacity>
</View>
);
}
export function OnboardingScreen() {
const navigation = useNavigation();
const [currentIndex, setCurrentIndex] = useState(0);
const [selectedFolders, setSelectedFolders] = useState<StoredFolder[]>([]);
const pendingSteps = onboardingStorage.getPendingSteps();
const step = pendingSteps[currentIndex];
const complete = useCallback(() => {
onboardingStorage.setCompletedVersion(CURRENT_ONBOARDING_VERSION);
navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'Home' }] }));
}, [navigation]);
const handlePickDirectory = useCallback(async () => {
try {
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!result.granted) return;
const dirUri = result.directoryUri;
const parts = dirUri.split('/');
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
const folder = safDirectory.addFolder(dirUri, dirName);
setSelectedFolders((prev) => [...prev, folder]);
} catch (err) {
console.error('[Onboarding] pickDirectory error:', err);
}
}, []);
const handleRemoveFolder = useCallback((folder: StoredFolder) => {
safDirectory.removeFolder(folder.id);
setSelectedFolders((prev) => prev.filter((f) => f.id !== folder.id));
}, []);
const handleNext = useCallback(async () => {
if (!step) return;
onboardingStorage.markStepSeen(step.id);
if (currentIndex < pendingSteps.length - 1) {
setCurrentIndex(currentIndex + 1);
setSelectedFolders([]);
} else {
complete();
}
}, [step, currentIndex, pendingSteps.length, complete]);
const handleSkip = useCallback(() => {
complete();
}, [complete]);
useEffect(() => {
if (!step) {
complete();
}
}, [step, complete]);
if (!step) {
return null;
}
const isFolderStep = step.action?.type === 'pick_directory';
return (
<View style={styles.container}>
<View style={styles.skipContainer}>
<TouchableOpacity onPress={handleSkip}>
<Text style={styles.skipText}>Passer</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.scrollContent} contentContainerStyle={styles.scrollInner}>
{isFolderStep ? (
<SelectFoldersStep
selectedFolders={selectedFolders}
onAddFolder={handlePickDirectory}
onRemoveFolder={handleRemoveFolder}
/>
) : (
<View style={styles.welcomeContent}>
<View style={styles.iconContainer}>
<MaterialIcons name="waving-hand" size={64} color="#1976D2" />
</View>
<Text style={styles.title}>{step.title}</Text>
<Text style={styles.description}>{step.description}</Text>
</View>
)}
</ScrollView>
<View style={styles.footer}>
<View style={styles.dots}>
{pendingSteps.map((_, i) => (
<View
key={i}
style={[styles.dot, i === currentIndex && styles.dotActive]}
/>
))}
</View>
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
<Text style={styles.nextBtnText}>
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
</Text>
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
skipContainer: {
alignItems: 'flex-end',
paddingHorizontal: 20,
paddingTop: 60,
},
skipText: {
fontSize: 16,
color: '#999',
},
scrollContent: {
flex: 1,
},
scrollInner: {
flexGrow: 1,
},
welcomeContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
},
folderStepContent: {
flex: 1,
alignItems: 'center',
paddingHorizontal: 40,
paddingTop: 40,
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#E3F2FD',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 40,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#333',
textAlign: 'center',
marginBottom: 16,
},
description: {
fontSize: 16,
color: '#666',
textAlign: 'center',
lineHeight: 24,
marginBottom: 20,
},
folderList: {
width: '100%',
marginBottom: 16,
},
selectedFolderRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fafafa',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 8,
gap: 10,
},
selectedFolderName: {
flex: 1,
fontSize: 15,
color: '#333',
},
removeBtn: {
padding: 4,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F57C00',
borderRadius: 12,
paddingHorizontal: 24,
paddingVertical: 14,
gap: 10,
width: '100%',
},
actionBtnText: {
fontSize: 16,
color: '#fff',
fontWeight: '600',
},
footer: {
paddingHorizontal: 40,
paddingBottom: 60,
alignItems: 'center',
gap: 24,
},
dots: {
flexDirection: 'row',
gap: 8,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#ddd',
},
dotActive: {
backgroundColor: '#1976D2',
width: 24,
},
nextBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1976D2',
borderRadius: 12,
paddingHorizontal: 32,
paddingVertical: 14,
gap: 8,
},
nextBtnText: {
fontSize: 16,
color: '#fff',
fontWeight: '600',
},
});
-350
View File
@@ -1,350 +0,0 @@
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, TextInput, Alert, ActivityIndicator, Modal, Dimensions } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { useBatchStore } from '../hooks/useBatchStore';
import { usePdfGeneration } from '../hooks/usePdfGeneration';
import { useUpload } from '../hooks/useUpload';
import { CapturedPhoto } from '../types';
const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS;
type PendingReviewRouteParams = {
PendingReview: { batchId: string; photoIds: string[] };
};
export function PendingReviewScreen() {
const route = useRoute<RouteProp<PendingReviewRouteParams, 'PendingReview'>>();
const navigation = useNavigation();
const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore();
const { generatePdf, generating, progress } = usePdfGeneration();
const upload = useUpload();
const batch = getBatch(route.params.batchId);
const initialPhotos = (batch?.photos ?? []).filter((p) =>
route.params.photoIds.includes(p.id)
);
const [photos, setPhotos] = useState<CapturedPhoto[]>(initialPhotos);
const [selectedSet, setSelectedSet] = useState<Set<string>>(new Set(initialPhotos.map((p) => p.id)));
const [batchTags, setBatchTags] = useState<string[]>(batch?.tags ?? []);
const [tagInput, setTagInput] = useState('');
const [previewUri, setPreviewUri] = useState<string | null>(null);
const orderedPhotos = photos.filter((p) => selectedSet.has(p.id));
const togglePhoto = (id: string) => {
setSelectedSet((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddTag = () => {
const tag = tagInput.trim().toLowerCase();
if (!tag || batchTags.includes(tag)) return;
setBatchTags((prev) => [...prev, tag]);
addTagToBatch(route.params.batchId, tag);
setTagInput('');
};
const handleRemoveTag = (tag: string) => {
setBatchTags((prev) => prev.filter((t) => t !== tag));
removeTagFromBatch(route.params.batchId, tag);
};
const handleFinalize = async () => {
if (orderedPhotos.length === 0) {
Alert.alert('Aucune photo', 'Sélectionnez au moins une photo');
return;
}
const pdfUri = await generatePdf(orderedPhotos.map((p) => ({ uri: p.uri })));
if (!pdfUri) {
Alert.alert('Erreur', 'Échec de la génération du PDF');
return;
}
Alert.alert(
'PDF généré',
'Voulez-vous uploader le fichier ?',
[
{ text: 'Annuler', style: 'cancel', onPress: () => navigation.goBack() },
{
text: 'Uploader',
onPress: async () => {
try {
const pdfName = `${batch?.name ?? 'document'}.pdf`;
const pdfUriClean = pdfUri.startsWith('file://') ? pdfUri : 'file://' + pdfUri;
await upload.mutateAsync([
{ uri: pdfUriClean, type: 'application/pdf', name: pdfName },
]);
Alert.alert('Succès', 'PDF uploadé avec succès', [
{ text: 'OK', onPress: () => navigation.navigate('Home' as never) },
]);
} catch (e: any) {
const msg = e?.message || e?.toString() || "Erreur inconnue";
Alert.alert('Erreur', `Échec de l'upload du PDF: ${msg}`);
}
},
},
]
);
};
if (!batch) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Lot introuvable</Text>
</View>
);
}
const renderPhoto = ({ item, index }: { item: CapturedPhoto; index: number }) => {
const isSelected = selectedSet.has(item.id);
const pageNumber = isSelected
? orderedPhotos.findIndex((p) => p.id === item.id) + 1
: null;
return (
<TouchableOpacity
style={styles.gridItem}
onPress={() => togglePhoto(item.id)}
onLongPress={() => setPreviewUri(item.uri)}
>
<Image source={{ uri: item.uri }} style={styles.thumb} />
{isSelected && (
<View style={styles.selectedOverlay}>
<Text style={styles.pageNumber}>{pageNumber}</Text>
</View>
)}
</TouchableOpacity>
);
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Réorganiser les photos</Text>
<Text style={styles.subtitle}>
{orderedPhotos.length}/{photos.length} sélectionnée{orderedPhotos.length > 1 ? 's' : ''}
</Text>
</View>
<FlatList
data={photos}
numColumns={NUM_COLUMNS}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
renderItem={renderPhoto}
/>
<View style={styles.tagSection}>
<Text style={styles.sectionTitle}>Tags</Text>
<View style={styles.tagRow}>
{batchTags.map((tag) => (
<TouchableOpacity key={tag} style={styles.tagChip} onPress={() => handleRemoveTag(tag)}>
<Text style={styles.tagText}>{tag} </Text>
</TouchableOpacity>
))}
</View>
<View style={styles.tagInputRow}>
<TextInput
style={styles.tagInput}
placeholder="Ajouter un tag..."
value={tagInput}
onChangeText={setTagInput}
onSubmitEditing={handleAddTag}
returnKeyType="done"
/>
<TouchableOpacity style={styles.tagAddBtn} onPress={handleAddTag}>
<Text style={styles.tagAddText}>+</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.footer}>
{generating && (
<View style={styles.progressRow}>
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.progressText}>Génération du PDF... {progress}%</Text>
</View>
)}
<TouchableOpacity
style={[styles.finalizeBtn, generating && styles.finalizeBtnDisabled]}
onPress={handleFinalize}
disabled={generating}
>
{generating ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.finalizeText}>📄 Finaliser et uploader le PDF</Text>
)}
</TouchableOpacity>
</View>
<Modal visible={previewUri !== null} transparent animationType="fade" onRequestClose={() => setPreviewUri(null)}>
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setPreviewUri(null)}>
{previewUri && (
<Image source={{ uri: previewUri }} style={styles.previewImage} resizeMode="contain" />
)}
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: '#666',
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#666',
},
grid: {
padding: 16,
},
gridRow: {
gap: 6,
},
gridItem: {
width: ITEM_SIZE,
height: ITEM_SIZE,
borderRadius: 6,
overflow: 'hidden',
backgroundColor: '#f0f0f0',
marginBottom: 6,
},
thumb: {
width: '100%',
height: '100%',
},
selectedOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(25,118,210,0.35)',
justifyContent: 'center',
alignItems: 'center',
},
pageNumber: {
color: '#fff',
fontSize: 28,
fontWeight: '800',
},
tagSection: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginBottom: 8,
},
tagChip: {
backgroundColor: '#E3F2FD',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
tagText: {
fontSize: 13,
color: '#1976D2',
},
tagInputRow: {
flexDirection: 'row',
gap: 8,
},
tagInput: {
flex: 1,
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
padding: 10,
fontSize: 14,
},
tagAddBtn: {
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
},
tagAddText: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
},
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 8,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
progressText: {
fontSize: 14,
color: '#666',
},
finalizeBtn: {
backgroundColor: '#1976D2',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
},
finalizeBtnDisabled: {
opacity: 0.6,
},
finalizeText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.9)',
justifyContent: 'center',
alignItems: 'center',
},
previewImage: {
width: SCREEN_WIDTH * 0.95,
height: '80%',
},
});
-164
View File
@@ -1,164 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useAuth } from '../contexts/AuthContext';
export function RegisterScreen({ navigation }: any) {
const { register } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleRegister() {
if (!username.trim() || !password || !confirmPassword) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
if (password !== confirmPassword) {
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
return;
}
if (password.length < 8) {
Alert.alert('Erreur', 'Le mot de passe doit contenir au moins 8 caractères');
return;
}
setLoading(true);
try {
await register(username.trim(), password);
} catch (error: any) {
Alert.alert('Erreur', error.message || "Inscription échouée");
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.inner}>
<Text style={styles.title}>Dot.</Text>
<Text style={styles.subtitle}>Créez votre compte</Text>
<TextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor="#999"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
secureTextEntry
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Confirmer le mot de passe"
placeholderTextColor="#999"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
editable={!loading}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>S'inscrire</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.navigate('Login')}
disabled={loading}
>
<Text style={styles.linkText}>Déjà un compte ? Se connecter</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
inner: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 32,
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 32,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 16,
},
button: {
backgroundColor: '#000',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#000',
fontSize: 14,
},
});
-273
View File
@@ -1,273 +0,0 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Camera } from 'react-native-vision-camera';
import { useCameraCapture } from '../hooks/useCameraCapture';
import { UploadProgress } from '../components/UploadProgress';
import { PhotoThumbnailStrip } from '../components/PhotoThumbnailStrip';
import { useBatchStore } from '../hooks/useBatchStore';
type RootStackParamList = {
Home: undefined;
Upload: undefined;
Scan: undefined;
Search: undefined;
BatchReview: { batchId: string };
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export function ScanScreen() {
const navigation = useNavigation<NavigationProp>();
const {
cameraRef,
hasPermission,
requestPermission,
device,
photoOutput,
capturePhoto,
captureStatus,
captureError,
isActive,
torchMode,
toggleTorch,
onStarted,
onStopped,
capturedPhotos,
removeCapturedPhoto,
clearCapturedPhotos,
capturedCount,
} = useCameraCapture();
const { saveBatch } = useBatchStore();
useEffect(() => {
if (!hasPermission) {
requestPermission();
}
}, [hasPermission, requestPermission]);
const statusToUploadProgress: Record<string, 'idle' | 'uploading' | 'processing' | 'success' | 'error'> = {
idle: 'idle',
capturing: 'uploading',
uploading: 'uploading',
success: 'success',
error: 'error',
};
const finishBatch = () => {
if (capturedPhotos.length === 0) return;
const batchId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const now = new Date();
const name = `Lot du ${now.toLocaleDateString('fr-FR')} ${now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
saveBatch({
id: batchId,
name,
createdAt: now.toISOString(),
photos: capturedPhotos,
tags: [],
});
clearCapturedPhotos();
navigation.navigate('BatchReview', { batchId });
};
if (!hasPermission) {
return (
<View style={styles.center}>
<Text style={styles.permissionText}>Permission caméra requise</Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}>Accorder l'accès</Text>
</TouchableOpacity>
</View>
);
}
if (!device) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1976D2" />
<Text style={styles.loadingText}>Caméra initialisation...</Text>
</View>
);
}
return (
<View style={styles.container}>
<Camera
ref={cameraRef}
style={StyleSheet.absoluteFill}
isActive={isActive}
device={device}
outputs={[photoOutput]}
onStarted={onStarted}
onStopped={onStopped}
/>
<View style={styles.viewfinderOverlay} pointerEvents="none">
<View style={styles.viewfinderFrame} />
</View>
<View style={styles.topOverlay}>
<UploadProgress
status={statusToUploadProgress[captureStatus]}
error={captureError}
totalCount={1}
uploadedCount={captureStatus === 'success' ? 1 : 0}
/>
{capturedCount > 0 && (
<View style={styles.batchInfo}>
<Text style={styles.batchInfoText}>{capturedCount} photo{capturedCount > 1 ? 's' : ''} prise{capturedCount > 1 ? 's' : ''}</Text>
</View>
)}
</View>
<PhotoThumbnailStrip photos={capturedPhotos} onRemove={removeCapturedPhoto} />
<View style={styles.bottomBar}>
<TouchableOpacity style={styles.torchButton} onPress={toggleTorch}>
<Text style={styles.torchIcon}>{torchMode === 'on' ? '🔦' : '💡'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.captureButton, (captureStatus === 'capturing' || captureStatus === 'uploading') && styles.captureButtonDisabled]}
onPress={capturePhoto}
disabled={captureStatus === 'capturing' || captureStatus === 'uploading'}
>
<View style={styles.captureInner} />
</TouchableOpacity>
{capturedCount > 0 ? (
<TouchableOpacity style={styles.finishButton} onPress={finishBatch}>
<Text style={styles.finishText}>✓</Text>
</TouchableOpacity>
) : (
<View style={styles.torchButton} />
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
padding: 24,
},
permissionText: {
fontSize: 18,
color: '#fff',
textAlign: 'center',
marginBottom: 24,
},
permissionButton: {
backgroundColor: '#1976D2',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 8,
},
permissionButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
loadingText: {
fontSize: 16,
color: '#fff',
marginTop: 16,
},
viewfinderOverlay: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
},
viewfinderFrame: {
width: '85%',
maxWidth: 400,
aspectRatio: 3 / 4,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.6)',
borderRadius: 12,
},
topOverlay: {
position: 'absolute',
top: 60,
left: 16,
right: 16,
},
batchInfo: {
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 8,
padding: 8,
marginTop: 8,
alignItems: 'center',
},
batchInfoText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
bottomBar: {
position: 'absolute',
bottom: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
paddingHorizontal: 32,
},
torchButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center',
alignItems: 'center',
},
torchIcon: {
fontSize: 24,
},
captureButton: {
width: 76,
height: 76,
borderRadius: 38,
borderWidth: 4,
borderColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
},
captureButtonDisabled: {
opacity: 0.5,
},
captureInner: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#fff',
},
finishButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#4CAF50',
justifyContent: 'center',
alignItems: 'center',
},
finishText: {
color: '#fff',
fontSize: 22,
fontWeight: '700',
},
});
-62
View File
@@ -1,62 +0,0 @@
import React, { useState } from 'react';
import { View, TextInput, FlatList, StyleSheet, Text } from 'react-native';
import { useSearch } from '../hooks/useSearch';
import { FileCard } from '../components/FileCard';
import { FileItem } from '../types';
export function SearchScreen() {
const [query, setQuery] = useState('');
const { data, isLoading } = useSearch(query);
const renderItem = ({ item }: { item: FileItem }) => (
<FileCard
file={item}
onPress={(file) => console.log('File pressed:', file.id)}
/>
);
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="Rechercher un fichier..."
value={query}
onChangeText={setQuery}
autoCapitalize="none"
/>
{isLoading && <Text style={styles.loading}>Recherche en cours...</Text>}
<FlatList
data={data || []}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
input: {
backgroundColor: '#fff',
padding: 12,
margin: 16,
borderRadius: 8,
fontSize: 16,
borderWidth: 1,
borderColor: '#e0e0e0',
},
loading: {
textAlign: 'center',
color: '#666',
marginBottom: 8,
},
list: {
padding: 16,
},
});
-490
View File
@@ -1,490 +0,0 @@
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Alert,
Animated,
Easing,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { fileStore, FileRecord } from '../services/fileStore';
import { useSyncQueue, useSyncProgress } from '../hooks/useSyncQueue';
import { useAutoSync } from '../hooks/useAutoSync';
import { useSyncPush } from '../hooks/useSyncPush';
import { useUploadQueue } from '../hooks/useUploadQueue';
import { UploadTask } from '../services/uploadQueue';
function uploadStatusIcon(task: UploadTask) {
switch (task.status) {
case 'pending':
return <MaterialIcons name="schedule" size={20} color="#FFA000" />;
case 'uploading':
return <ActivityIndicator size="small" color="#1976D2" />;
case 'done':
return <MaterialIcons name="check-circle" size={20} color="#4CAF50" />;
case 'error':
return <MaterialIcons name="error" size={20} color="#E53935" />;
}
}
function SpinningSyncIcon({ size, color }: { size: number; color: string }) {
const spin = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.timing(spin, {
toValue: 1,
duration: 1200,
easing: Easing.linear,
useNativeDriver: true,
})
);
animation.start();
return () => animation.stop();
}, [spin]);
const rotate = spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
return (
<Animated.View style={{ transform: [{ rotate }] }}>
<MaterialIcons name="sync" size={size} color={color} />
</Animated.View>
);
}
type PendingFile = { id: string; name: string };
const PendingSyncCard = React.memo(function PendingSyncCard({
syncing,
pendingCount,
shortList,
onSyncPress,
onCancel,
}: {
syncing: boolean;
pendingCount: number;
shortList: PendingFile[] | null;
onSyncPress: () => void;
onCancel: () => void;
}) {
return (
<View style={styles.pendingCard}>
<TouchableOpacity
style={styles.pendingCardTop}
onPress={syncing ? undefined : onSyncPress}
disabled={syncing}
activeOpacity={0.8}
>
<View style={styles.pendingCardIcon}>
{syncing ? (
<SpinningSyncIcon size={28} color="#1976D2" />
) : (
<MaterialIcons name="cloud-upload" size={28} color="#1976D2" />
)}
</View>
<View style={styles.pendingCardInfo}>
<Text style={styles.pendingCardTitle}>
{syncing
? 'Synchronisation en cours...'
: pendingCount > 1
? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés`
: 'Vous avez 1 fichier local pouvant être synchronisé'}
</Text>
<Text style={styles.pendingCardSubtitle}>
{syncing
? shortList && shortList[0]
? `En cours : ${shortList[0].name}`
: 'Synchronisation en cours...'
: 'Appuyer maintenant pour les synchroniser'}
</Text>
</View>
{!syncing && <MaterialIcons name="chevron-right" size={24} color="#999" />}
</TouchableOpacity>
{syncing && shortList && shortList.length > 0 && (
<View style={styles.pendingList}>
{shortList.map((f, i) => (
<View key={f.id} style={styles.pendingRow}>
{i === 0 ? (
<SpinningSyncIcon size={16} color="#1976D2" />
) : (
<MaterialIcons name="schedule" size={16} color="#FFA000" />
)}
<Text
style={[styles.pendingRowText, i === 0 && styles.pendingRowActive]}
numberOfLines={1}
>
{f.name}
</Text>
</View>
))}
</View>
)}
{syncing && (
<TouchableOpacity style={styles.stopBtn} onPress={onCancel} activeOpacity={0.8}>
<MaterialIcons name="stop-circle" size={18} color="#fff" />
<Text style={styles.stopBtnText}>Arrêter la synchronisation</Text>
</TouchableOpacity>
)}
</View>
);
});
export function SyncDetailScreen() {
const { pendingCount, isSyncing, refresh } = useSyncQueue();
const { syncProgress } = useSyncProgress();
const { syncManually, cancelSync } = useAutoSync();
const { push } = useSyncPush();
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
const [listVersion, setListVersion] = useState(0);
const bumpList = useCallback(() => setListVersion((v) => v + 1), []);
useEffect(() => {
const interval = setInterval(bumpList, 5000);
return () => clearInterval(interval);
}, [bumpList]);
const errorFiles = useMemo(() => {
return fileStore.getErrorFiles();
}, [listVersion]);
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
const handleSyncAll = useCallback(async () => {
for (const f of fileStore.getErrorFiles()) {
fileStore.resetSyncError(f.id);
}
await syncManually();
try {
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
const serverId = await getStoredDeviceServerId();
if (serverId) {
await push(serverId);
}
} catch { }
refresh();
bumpList();
}, [syncManually, push, refresh, bumpList]);
const handleSyncButtonPress = useCallback(async () => {
if (uploadErrors.length > 0) {
retryAll();
}
await handleSyncAll();
}, [uploadErrors.length, retryAll, handleSyncAll]);
const handleErrorFilePress = useCallback((file: FileRecord) => {
Alert.alert(
'Fichier en erreur',
'Réessayer la synchronisation de ce fichier ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Réessayer',
onPress: () => {
fileStore.resetSyncError(file.id);
syncManually().finally(() => {
refresh();
bumpList();
});
},
},
],
);
}, [syncManually, refresh, bumpList]);
const handleTaskPress = useCallback((task: UploadTask) => {
if (task.status !== 'error') return;
Alert.alert(
'Erreur d\'upload',
task.error || 'Erreur inconnue',
[
{ text: 'Annuler', style: 'cancel' },
{ text: 'Réessayer', onPress: () => retry(task.id) },
],
);
}, [retry]);
const syncing = !!syncProgress;
const hasUploads = uploadTasks.length > 0;
const hasPending = pendingCount > 0 || syncing;
const hasErrors = errorFiles.length > 0;
const hasContent = hasUploads || hasPending || hasErrors;
const shortList = useMemo(() => {
if (!syncProgress) return null;
const { files, currentIndex } = syncProgress;
const start = Math.max(0, currentIndex);
return files.slice(start, start + 5);
}, [syncProgress]);
return (
<View style={styles.container}>
{!hasContent ? (
<View style={styles.empty}>
<MaterialIcons name="cloud-done" size={48} color="#4CAF50" />
<Text style={styles.emptyTitle}>Tout est synchronisé</Text>
<Text style={styles.emptySubtitle}>
Aucun fichier en attente
</Text>
</View>
) : (
<FlatList
data={[
...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []),
...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })),
...(hasErrors ? [{ type: 'section', label: 'Fichiers en erreur' } as const] : []),
...errorFiles.map((f) => ({ type: 'error' as const, data: f })),
...(hasPending ? [{ type: 'pendingCard' } as const] : []),
]}
keyExtractor={(item) =>
item.type === 'section' || item.type === 'pendingCard'
? item.type === 'pendingCard'
? 'pending-card'
: item.label
: item.data.id
}
extraData={[isSyncing, syncProgress]}
renderItem={({ item }) => {
if (item.type === 'section') {
return <Text style={styles.headerText}>{item.label}</Text>;
}
if (item.type === 'pendingCard') {
return (
<PendingSyncCard
syncing={syncing}
pendingCount={pendingCount}
shortList={shortList}
onSyncPress={handleSyncButtonPress}
onCancel={cancelSync}
/>
);
}
if (item.type === 'upload') {
const task = item.data;
return (
<TouchableOpacity
style={[styles.fileRow, task.status === 'error' && styles.fileRowError]}
onPress={() => handleTaskPress(task)}
activeOpacity={task.status === 'error' ? 0.6 : 1}
>
{uploadStatusIcon(task)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{task.file.name}</Text>
{task.status === 'uploading' && (
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${task.progress}%` }]} />
</View>
)}
{task.status === 'error' && task.error && (
<Text style={styles.errorText} numberOfLines={1}>{task.error}</Text>
)}
{task.status === 'done' && (
<Text style={styles.doneText}>Upload terminé</Text>
)}
{task.status === 'pending' && (
<Text style={styles.pendingText}>En attente</Text>
)}
</View>
</TouchableOpacity>
);
}
if (item.type === 'error') {
const file = item.data;
return (
<TouchableOpacity
style={[styles.fileRow, styles.fileRowError]}
onPress={() => handleErrorFilePress(file)}
activeOpacity={0.6}
>
<MaterialIcons name="error" size={20} color="#E53935" />
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
<Text style={styles.errorText}>
Échec de synchronisation · toucher pour réessayer
</Text>
</View>
<MaterialIcons name="refresh" size={18} color="#E53935" />
</TouchableOpacity>
);
}
return null;
}}
contentContainerStyle={styles.list}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
list: {
padding: 16,
},
headerText: {
fontSize: 14,
color: '#666',
marginBottom: 12,
},
fileRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 10,
padding: 12,
marginBottom: 8,
gap: 12,
},
pendingCard: {
backgroundColor: '#fff',
borderRadius: 14,
padding: 16,
marginBottom: 8,
gap: 14,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
pendingCardTop: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
pendingCardIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#E3F2FD',
justifyContent: 'center',
alignItems: 'center',
},
pendingCardInfo: {
flex: 1,
},
pendingCardTitle: {
fontSize: 15,
color: '#333',
fontWeight: '600',
},
pendingCardSubtitle: {
fontSize: 13,
color: '#1976D2',
marginTop: 4,
},
pendingList: {
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
paddingTop: 12,
gap: 8,
},
pendingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
pendingRowText: {
flex: 1,
fontSize: 13,
color: '#999',
},
pendingRowActive: {
color: '#1976D2',
fontWeight: '500',
},
stopBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#E53935',
borderRadius: 8,
paddingVertical: 10,
marginTop: 4,
},
stopBtnText: {
fontSize: 14,
color: '#fff',
fontWeight: '600',
},
fileInfo: {
flex: 1,
},
fileName: {
fontSize: 15,
color: '#333',
fontWeight: '500',
},
fileMeta: {
fontSize: 12,
color: '#999',
marginTop: 2,
},
localBadge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#f5f5f5',
justifyContent: 'center',
alignItems: 'center',
},
fileRowError: {
backgroundColor: '#FFF0F0',
},
progressBar: {
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
marginTop: 6,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#1976D2',
borderRadius: 2,
},
errorText: {
fontSize: 12,
color: '#E53935',
marginTop: 2,
},
doneText: {
fontSize: 12,
color: '#4CAF50',
marginTop: 2,
},
pendingText: {
fontSize: 12,
color: '#FFA000',
marginTop: 2,
},
empty: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
},
emptySubtitle: {
fontSize: 14,
color: '#999',
},
});