diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 0b8fc66..743ff72 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -86,6 +86,7 @@ export function HomeScreen() { const debouncedSearch = useDebounce(searchQuery, 250); const [filters, setFilters] = useState({ name: true, ocrText: true }); const [sort, setSort] = useState({ key: 'date', direction: 'desc' }); + const listRef = useRef>(null); const [keyboardOpen, setKeyboardOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [tagModalVisible, setTagModalVisible] = useState(false); @@ -106,14 +107,34 @@ export function HomeScreen() { const { tasks: uploadTasks } = useUploadQueue(); useAutoSync(); + const loadingMoreRef = useRef(false); + const listLayoutHeightRef = useRef(0); + const listContentHeightRef = useRef(0); + const loadMore = useCallback(() => { - if (isFetching) return; + 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); } - }, [isFetching, data?.meta?.total, data?.data?.length]); + }, [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; @@ -441,13 +462,29 @@ export function HomeScreen() { )} item.id} contentContainerStyle={styles.list} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" - onEndReached={loadMore} + 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 ? ( diff --git a/mobile/components/FileCard.tsx b/mobile/components/FileCard.tsx index 55c370b..eef9f3d 100644 --- a/mobile/components/FileCard.tsx +++ b/mobile/components/FileCard.tsx @@ -41,7 +41,7 @@ function getExtension(fileName: string): string { return ext ? ext.toUpperCase() : ''; } -export function FileCard({ file, onPress, onLongPress, selected }: FileCardProps) { +export const FileCard = React.memo(function FileCard({ file, onPress, onLongPress, selected }: FileCardProps) { const info = getFileInfo(file.mimeType, file.name); const ext = getExtension(file.name); @@ -108,6 +108,12 @@ export function FileCard({ file, onPress, onLongPress, selected }: FileCardProps )} + {file.syncStatus && !isUploading && ( + + + + )} + {isUploading ? ( @@ -131,7 +137,7 @@ export function FileCard({ file, onPress, onLongPress, selected }: FileCardProps ); -} +}); const styles = StyleSheet.create({ container: { @@ -216,6 +222,10 @@ const styles = StyleSheet.create({ fontSize: 12, color: '#999', }, + statusRow: { + flexDirection: 'row', + marginBottom: 6, + }, preview: { fontSize: 13, color: '#666', diff --git a/mobile/components/SyncStatusBadge.tsx b/mobile/components/SyncStatusBadge.tsx index 4f2d474..74e2797 100644 --- a/mobile/components/SyncStatusBadge.tsx +++ b/mobile/components/SyncStatusBadge.tsx @@ -1,25 +1,35 @@ import React from 'react'; -import { View, StyleSheet } from 'react-native'; +import { View, Text, StyleSheet } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { SyncStatus } from '../types'; interface SyncStatusBadgeProps { status: SyncStatus; size?: number; + showLabel?: boolean; } -const STATUS_CONFIG: Record = { - local: { icon: 'phone-android', color: '#757575', bg: 'rgba(245,245,245,0.9)' }, - syncing: { icon: 'sync', color: '#FF9800', bg: 'rgba(255,243,224,0.9)' }, - synced: { icon: 'sync', color: '#4CAF50', bg: 'rgba(232,245,233,0.9)' }, - cloud: { icon: 'cloud', color: '#1976D2', bg: 'rgba(227,242,253,0.9)' }, - conflict: { icon: 'warning', color: '#E53935', bg: 'rgba(255,235,238,0.9)' }, +const STATUS_CONFIG: Record = { + local: { icon: 'phone-android', color: '#757575', bg: '#F5F5F5', label: 'Local' }, + syncing: { icon: 'sync', color: '#FF9800', bg: '#FFF3E0', label: 'Sync...' }, + synced: { icon: 'sync', color: '#4CAF50', bg: '#E8F5E9', label: 'Les deux' }, + cloud: { icon: 'cloud', color: '#1976D2', bg: '#E3F2FD', label: 'Cloud' }, + conflict: { icon: 'warning', color: '#E53935', bg: '#FFEBEE', label: 'Conflit' }, }; -export function SyncStatusBadge({ status, size = 16 }: SyncStatusBadgeProps) { +export function SyncStatusBadge({ status, size = 16, showLabel }: SyncStatusBadgeProps) { const config = STATUS_CONFIG[status]; const iconSize = Math.round(size * 0.7); + if (showLabel) { + return ( + + + {config.label} + + ); + } + return ( @@ -37,4 +47,17 @@ const styles = StyleSheet.create({ borderWidth: 0.5, borderColor: 'rgba(0,0,0,0.1)', }, + pill: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 10, + }, + pillText: { + fontSize: 12, + fontWeight: '600', + }, }); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 2d84fc4..cd75448 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -19,8 +19,10 @@ "drizzle-orm": "^0.45.2", "expo": "~57.0.8", "expo-background-task": "~57.0.6", + "expo-blur": "~57.0.2", "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.1", + "expo-haptics": "~57.0.2", "expo-image": "~57.0.1", "expo-image-manipulator": "~57.0.14", "expo-print": "~57.0.1", @@ -4321,6 +4323,17 @@ "expo": "*" } }, + "node_modules/expo-blur": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.2.tgz", + "integrity": "sha512-Aoud8H8lmlNkbRufyvRLefmGFELdBf1n5Te/Xm+Zx8ORINH+aXL+gKb5mbftFSha860+I7pMArz77TBYz8HDVg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-document-picker": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.1.tgz", @@ -4355,6 +4368,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.2.tgz", + "integrity": "sha512-vPths6zTxxaGaemC7D1GKbw1iOnOAAL5oAcCFVSgVNWnuLCBefL1LkhRBaJsepiMXRh2y3vwPiXkPaf6d16blA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.1.tgz", diff --git a/mobile/package.json b/mobile/package.json index 4218385..a049bf5 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -14,8 +14,10 @@ "drizzle-orm": "^0.45.2", "expo": "~57.0.8", "expo-background-task": "~57.0.6", + "expo-blur": "~57.0.2", "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.1", + "expo-haptics": "~57.0.2", "expo-image": "~57.0.1", "expo-image-manipulator": "~57.0.14", "expo-print": "~57.0.1",