From 3c7a1d18daefe41cfb7ad3e8d7d207df94a91024 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 29 Jul 2026 20:16:20 +0200 Subject: [PATCH] header size --- mobile/App.tsx | 2 +- mobile/app/file-detail.tsx | 595 ++++++++++++++++++++++++--------- mobile/components/FileCard.tsx | 5 +- 3 files changed, 438 insertions(+), 164 deletions(-) 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 && ( - + )}