diff --git a/mobile/App.tsx b/mobile/App.tsx
index b4045d0..5dd9364 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -92,7 +92,7 @@ function AppNavigator() {
diff --git a/mobile/app/file-detail.tsx b/mobile/app/file-detail.tsx
index 05e4f00..cd29191 100644
--- a/mobile/app/file-detail.tsx
+++ b/mobile/app/file-detail.tsx
@@ -1,26 +1,33 @@
-import React, { useRef, useState, useCallback, useEffect } from 'react';
+import React, { useRef, useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
- Image,
Dimensions,
ActivityIndicator,
- ScrollView,
Modal,
Pressable,
TouchableOpacity,
+ Share,
+ Alert,
} from 'react-native';
+import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
-import { RouteProp, useRoute } from '@react-navigation/native';
+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 { SyncStatusBadge } from '../components/SyncStatusBadge';
-import { GestureHandlerRootView } from 'react-native-gesture-handler';
+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;
@@ -42,8 +49,13 @@ type RootStackParamList = {
type FileDetailRouteProp = RouteProp;
+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);
@@ -55,14 +67,10 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev
const uri = isDevice ? deviceFile.localUri : (localEntry?.localUri ?? file?.url);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
- useEffect(() => {
- if (!uri) { setImageSize(null); return; }
- Image.getSize(
- uri,
- (w, h) => setImageSize({ width: w, height: h }),
- () => setImageSize(null),
- );
- }, [uri]);
+
+ const handleImageLoad = useCallback((event: { source: { width: number; height: number } }) => {
+ setImageSize({ width: event.source.width, height: event.source.height });
+ }, []);
const syncStatus: SyncStatus = isDevice
? 'local'
@@ -76,6 +84,26 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev
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 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;
@@ -85,7 +113,7 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev
await downloadFile.mutateAsync({
id: bid,
backendResourceId: bid,
- name: file?.name ?? localEntry?.name ?? fileId,
+ name: fileName,
mimeType: file?.mimeType ?? localEntry?.mimeType ?? 'application/octet-stream',
size: file?.size ?? localEntry?.size ?? 0,
createdAt: file?.createdAt ?? localEntry?.createdAt ?? new Date().toISOString(),
@@ -97,119 +125,269 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev
} catch {} finally {
setDownloading(false);
}
- }, [file, fileId, localEntry, downloadFile]);
+ }, [file, fileName, fileId, localEntry, downloadFile]);
- return (
-
- {hasPages ? (
- fullThumbnails.map((thumb, thumbIndex) => {
- const allImages: SelectedImage[] = fullThumbnails.map((t) => ({
- uri: t.url,
- width: SCREEN_WIDTH,
- height: t.height * (SCREEN_WIDTH / t.width),
- }));
- return (
- onSelectImage?.({ images: allImages, index: thumbIndex })}
+ 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 handleDelete = useCallback(() => {
+ setOptionsVisible(false);
+ Alert.alert(
+ 'Supprimer',
+ `Supprimer "${fileName}" définitivement ?`,
+ [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Supprimer',
+ style: 'destructive',
+ onPress: 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 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 (
+ onSelectImage?.({ images: allImages, index: thumbIndex })}
+ >
+
+
+ );
+ });
+ }
+ if (uri) {
+ return (
+
+ );
+ }
+ if (file) {
+ return (
+
+
+ {syncStatus === 'cloud' && (
+
-
-
- );
- })
- ) : (
-
- {uri ? (
- onSelectImage?.({
- images: [{ uri, width: SCREEN_WIDTH, height: imageSize ? imageSize.height * SCREEN_WIDTH / imageSize.width : SCREEN_WIDTH }],
- index: 0,
- })}
- >
-
-
- ) : file ? (
-
-
- {syncStatus === 'cloud' && (
-
- {downloading ? (
-
- ) : (
-
- )}
-
- {downloading ? 'Téléchargement...' : 'Télécharger'}
-
-
+ {downloading ? (
+
+ ) : (
+
)}
-
- ) : (
-
+
+ {downloading ? 'Téléchargement...' : 'Télécharger'}
+
+
)}
- )}
+ );
+ }
+ return ;
+ };
-
-
- {deviceFile?.name ?? file?.name ?? fileId}
-
+ if (deleting) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {
+ if (!hasPages && uri) {
+ const height = imageSize
+ ? imageSize.height * SCREEN_WIDTH / imageSize.width
+ : SCREEN_WIDTH;
+ onSelectImage?.({
+ images: [{ uri, width: SCREEN_WIDTH, height }],
+ index: 0,
+ });
+ }
+ }}
+ >
+
+ {renderImageContent()}
+
- {(deviceFile || file?.size != null) && (
-
- Taille : {((deviceFile ? 0 : file?.size) ?? 0 / 1024).toFixed(1)} Ko
-
- )}
+
+ {formattedDate}
- {(deviceFile?.createdAt || file?.createdAt) && (
-
- Ajouté le {new Date(deviceFile?.createdAt ?? file?.createdAt).toLocaleDateString('fr-FR', {
- day: 'numeric', month: 'long', year: 'numeric',
- })}
-
- )}
+ setOptionsVisible(true)} style={styles.headerBtn}>
+
+
+
- {file?.tags && file.tags.length > 0 && (
-
- Tags
-
- {file.tags.map((tag: any) => (
-
- ))}
+
+
+
+
+
+ {fileName}
+
+
+
+
+
+ {formattedDate ? (
+
+
+ {formattedDate}
+
+ ) : null}
+
+ {formattedSize ? (
+
+
+ {formattedSize}
+
+ ) : null}
+
+ {file?.tags && file.tags.length > 0 && (
+
+ Tags
+
+ {file.tags.map((tag: any) => (
+
+ ))}
+
+
+ )}
+
+ {file?.ocrText && (
+
+ Texte OCR
+ {file.ocrText}
+
+ )}
+
+
+
+
+ Partager
+
+
+
+ Supprimer
+
- )}
+
+
- {file?.ocrText && (
-
- Texte OCR
- {file.ocrText}
+ setOptionsVisible(false)}>
+ setOptionsVisible(false)}>
+
+
+
+ Partager
+
+
+
+
+ Supprimer
+
- )}
-
-
+
+
+
);
}
@@ -244,6 +422,9 @@ export function FileDetailScreen() {
disableIntervalMomentum
showsHorizontalScrollIndicator={false}
initialScrollIndex={initialIndex}
+ windowSize={5}
+ initialNumToRender={3}
+ maxToRenderPerBatch={3}
getItemLayout={(_, index) => ({
length: SCREEN_WIDTH,
offset: SCREEN_WIDTH * index,
@@ -302,49 +483,142 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#000',
},
+ center: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
pageWrapper: {
width: SCREEN_WIDTH,
},
- page: {
+ detailContainer: {
flex: 1,
- },
- pageContent: {
- flexGrow: 1,
- },
- imageContainer: {
backgroundColor: '#000',
},
+ imageCentered: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
image: {
width: SCREEN_WIDTH,
},
- pageImageContainer: {
- alignItems: 'center',
- backgroundColor: '#000',
- paddingVertical: 4,
- },
- pageImage: {
- width: SCREEN_WIDTH,
- },
- details: {
- backgroundColor: '#fff',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- marginTop: -16,
- padding: 20,
- },
- detailHeader: {
+ headerOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
- marginBottom: 8,
+ paddingHorizontal: 8,
+ paddingBottom: 8,
+ backgroundColor: 'rgba(0,0,0,0.3)',
},
- fileName: {
- fontSize: 18,
+ 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,
@@ -363,34 +637,33 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '600',
},
- meta: {
- fontSize: 14,
- color: '#666',
- marginBottom: 4,
+ optionsOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ justifyContent: 'flex-end',
+ paddingBottom: 40,
},
- tagsRow: {
+ optionsMenu: {
+ backgroundColor: '#fff',
+ borderRadius: 14,
+ marginHorizontal: 20,
+ overflow: 'hidden',
+ },
+ optionItem: {
flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 6,
- marginTop: 8,
+ alignItems: 'center',
+ gap: 14,
+ paddingVertical: 16,
+ paddingHorizontal: 20,
},
- tagsSection: {
- marginTop: 12,
- },
- sectionLabel: {
- fontSize: 14,
- fontWeight: '600',
+ optionText: {
+ fontSize: 16,
color: '#333',
- marginBottom: 4,
},
- ocrSection: {
- marginTop: 16,
- },
- ocrText: {
- fontSize: 14,
- color: '#555',
- lineHeight: 20,
- marginTop: 4,
+ optionDivider: {
+ height: 1,
+ backgroundColor: '#eee',
+ marginHorizontal: 20,
},
pagination: {
position: 'absolute',
diff --git a/mobile/components/FileCard.tsx b/mobile/components/FileCard.tsx
index 30a5ab5..1642593 100644
--- a/mobile/components/FileCard.tsx
+++ b/mobile/components/FileCard.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { View, Text, Image, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
+import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
+import { Image } from 'expo-image';
import { FileItem } from '../types';
import { TagChip } from './TagChip';
@@ -21,7 +22,7 @@ export function FileCard({ file, onPress }: FileCardProps) {
onPress?.(file)}>
{imageUri && (
-
+
)}