add files listing
This commit is contained in:
+25
-15
@@ -30,25 +30,35 @@ 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 };
|
||||
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, DeviceFileParam> };
|
||||
};
|
||||
|
||||
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
|
||||
|
||||
function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (state: ModalState) => void }) {
|
||||
const { data: imageData, isLoading: imageLoading } = useFileImage(fileId);
|
||||
const { data: fileData } = useFile(fileId);
|
||||
function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; deviceFile?: DeviceFileParam; onSelectImage?: (state: ModalState) => void }) {
|
||||
const isDevice = !!deviceFile;
|
||||
const { data: imageData, isLoading: imageLoading } = useFileImage(isDevice ? '' : fileId);
|
||||
const { data: fileData } = useFile(isDevice ? '' : fileId);
|
||||
const downloadFile = useDownloadFile();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const uri = imageData?.data?.url;
|
||||
const uri = isDevice ? deviceFile.localUri : (imageData?.data?.url);
|
||||
const file = fileData as any;
|
||||
|
||||
const localEntry = localFileRegistry.getByBackendId(fileId);
|
||||
const syncStatus: SyncStatus = localEntry
|
||||
? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced')
|
||||
: (uri ? 'synced' : 'cloud');
|
||||
const syncStatus: SyncStatus = isDevice
|
||||
? 'local'
|
||||
: localEntry
|
||||
? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced')
|
||||
: (uri ? 'synced' : 'cloud');
|
||||
|
||||
const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? [])
|
||||
.filter((t: Thumbnail) => t.resolutionLabel === 'full')
|
||||
@@ -141,19 +151,19 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
|
||||
|
||||
<View style={styles.details}>
|
||||
<View style={styles.detailHeader}>
|
||||
<Text style={styles.fileName}>{imageData?.data?.name ?? file?.name ?? fileId}</Text>
|
||||
<Text style={styles.fileName}>{deviceFile?.name ?? imageData?.data?.name ?? file?.name ?? fileId}</Text>
|
||||
<SyncStatusBadge status={syncStatus} size={20} />
|
||||
</View>
|
||||
|
||||
{imageData?.data?.size != null && (
|
||||
{(deviceFile || imageData?.data?.size != null) && (
|
||||
<Text style={styles.meta}>
|
||||
Taille : {(imageData.data.size / 1024).toFixed(1)} Ko
|
||||
Taille : {((deviceFile ? 0 : imageData?.data?.size) ?? 0 / 1024).toFixed(1)} Ko
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{file?.createdAt && (
|
||||
{(deviceFile?.createdAt || file?.createdAt) && (
|
||||
<Text style={styles.meta}>
|
||||
Ajouté le {new Date(file.createdAt).toLocaleDateString('fr-FR', {
|
||||
Ajouté le {new Date(deviceFile?.createdAt ?? file?.createdAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric', month: 'long', year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
@@ -183,7 +193,7 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
|
||||
|
||||
export function FileDetailScreen() {
|
||||
const route = useRoute<FileDetailRouteProp>();
|
||||
const { fileIds, initialIndex } = route.params;
|
||||
const { fileIds, initialIndex, deviceFiles } = route.params;
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
@@ -223,7 +233,7 @@ export function FileDetailScreen() {
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.pageWrapper}>
|
||||
<DetailItem fileId={item} onSelectImage={setModalState} />
|
||||
<DetailItem fileId={item} deviceFile={deviceFiles?.[item]} onSelectImage={setModalState} />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
+174
-2
@@ -10,6 +10,7 @@ import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
||||
import { FileItem, isFolder } from '../types';
|
||||
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
||||
import { FileThumbnail } from '../components/FileThumbnail';
|
||||
import { safDirectory, StoredFolder } from '../services/safDirectory';
|
||||
|
||||
const NUM_COLUMNS = 3;
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
@@ -19,7 +20,7 @@ type RootStackParamList = {
|
||||
Home: undefined;
|
||||
Upload: undefined;
|
||||
Scan: undefined;
|
||||
FileDetail: { fileIds: string[]; initialIndex: number };
|
||||
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
|
||||
FileEdit: { fileIds: string[] };
|
||||
Folder: { folderId: string; folderName: string };
|
||||
};
|
||||
@@ -133,7 +134,7 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte
|
||||
export function HomeScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { data, isLoading, error, hasPermission, requestPermission } = useUnifiedFiles();
|
||||
const { data, isLoading, error, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useUnifiedFiles();
|
||||
const deleteFile = useDeleteFile();
|
||||
const freeLocalSpace = useFreeLocalSpace();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -148,6 +149,7 @@ export function HomeScreen() {
|
||||
const moveFiles = useMoveFiles();
|
||||
const { data: foldersData } = useFolders();
|
||||
const [moveModalVisible, setMoveModalVisible] = useState(false);
|
||||
const [folderSheetVisible, setFolderSheetVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
||||
@@ -155,6 +157,16 @@ export function HomeScreen() {
|
||||
return () => { show.remove(); hide.remove(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => setFolderSheetVisible(true)} style={{ marginRight: 4, padding: 8 }}>
|
||||
<MaterialIcons name="settings" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const selectionMode = selectedIds.size > 0;
|
||||
|
||||
const files = data ?? [];
|
||||
@@ -269,15 +281,50 @@ export function HomeScreen() {
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedIds, moveFiles]);
|
||||
|
||||
const handleToggleFolderVisibility = useCallback((folderId: string) => {
|
||||
safDirectory.toggleVisibility(folderId);
|
||||
refreshFolders();
|
||||
}, [refreshFolders]);
|
||||
|
||||
const handleRemoveFolder = useCallback((folderId: string) => {
|
||||
Alert.alert(
|
||||
'Supprimer le dossier',
|
||||
'Le dossier sera retiré de la liste. Les fichiers resteront sur votre appareil.',
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
safDirectory.removeFolder(folderId);
|
||||
refreshFolders();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [refreshFolders]);
|
||||
|
||||
const handleAddFolder = useCallback(async () => {
|
||||
setFolderSheetVisible(false);
|
||||
await pickDirectory();
|
||||
}, [pickDirectory]);
|
||||
|
||||
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 filteredFiles) {
|
||||
if (f.isDeviceFile && f.localUri) {
|
||||
deviceFilesMap[f.id] = { localUri: f.localUri, name: f.name, mimeType: f.mimeType, createdAt: f.createdAt };
|
||||
}
|
||||
}
|
||||
navigation.navigate('FileDetail', {
|
||||
fileIds: filteredFiles.map((f) => f.id),
|
||||
initialIndex: fileIdToIndex.get(file.id) ?? 0,
|
||||
deviceFiles: Object.keys(deviceFilesMap).length > 0 ? deviceFilesMap : undefined,
|
||||
});
|
||||
}
|
||||
}, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]);
|
||||
@@ -333,6 +380,14 @@ export function HomeScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{files.length === 0 && !searchQuery && (
|
||||
<TouchableOpacity style={styles.folderScanBanner} onPress={pickDirectory}>
|
||||
<MaterialIcons name="folder-open" size={24} color="#F57C00" />
|
||||
<Text style={styles.folderScanText}>
|
||||
{folders.length > 0 ? 'Aucun fichier trouvé — re-sélectionner' : 'Scanner un dossier de votre appareil'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<FlatList
|
||||
data={groupKeys}
|
||||
keyExtractor={(item) => item}
|
||||
@@ -528,6 +583,45 @@ export function HomeScreen() {
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
<Modal visible={folderSheetVisible} transparent animationType="slide" onRequestClose={() => setFolderSheetVisible(false)}>
|
||||
<TouchableOpacity style={styles.bottomSheetOverlay} activeOpacity={1} onPress={() => setFolderSheetVisible(false)}>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.bottomSheet} onPress={() => {}}>
|
||||
<View style={styles.bottomSheetHandle} />
|
||||
<Text style={styles.bottomSheetTitle}>Mes dossiers</Text>
|
||||
{folders.length === 0 ? (
|
||||
<Text style={styles.emptyFoldersText}>Aucun dossier configuré</Text>
|
||||
) : (
|
||||
folders.map((folder) => (
|
||||
<View key={folder.id} style={styles.folderRow}>
|
||||
<MaterialIcons name="folder" size={20} color="#F57C00" />
|
||||
<Text style={styles.folderRowName} numberOfLines={1}>{folder.name}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleFolderVisibility(folder.id)}
|
||||
style={styles.folderRowAction}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={folder.visible ? 'visibility' : 'visibility-off'}
|
||||
size={20}
|
||||
color={folder.visible ? '#1976D2' : '#999'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRemoveFolder(folder.id)}
|
||||
style={styles.folderRowAction}
|
||||
>
|
||||
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<TouchableOpacity style={styles.addFolderBtn} onPress={handleAddFolder}>
|
||||
<MaterialIcons name="add" size={20} color="#fff" />
|
||||
<Text style={styles.addFolderBtnText}>Ajouter un dossier</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -561,6 +655,19 @@ const styles = StyleSheet.create({
|
||||
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',
|
||||
@@ -777,4 +884,69 @@ const styles = StyleSheet.create({
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyFoldersText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
folderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
gap: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f0f0f0',
|
||||
},
|
||||
folderRowName: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#333',
|
||||
},
|
||||
folderRowAction: {
|
||||
padding: 6,
|
||||
},
|
||||
addFolderBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F57C00',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
marginTop: 16,
|
||||
gap: 8,
|
||||
},
|
||||
addFolderBtnText: {
|
||||
fontSize: 15,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
bottomSheetOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
bottomSheet: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 32,
|
||||
maxHeight: '70%',
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ddd',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
bottomSheetTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
|
||||
import { onboardingStorage } from '../services/onboardingStorage';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
const ICON_MAP: Record<string, keyof typeof MaterialIcons.glyphMap> = {
|
||||
'waving-hand': 'waving-hand',
|
||||
'folder-off': 'folder-off',
|
||||
'create-new-folder': 'create-new-folder',
|
||||
};
|
||||
|
||||
export function OnboardingScreen() {
|
||||
const navigation = useNavigation();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const pendingSteps = onboardingStorage.getPendingSteps();
|
||||
|
||||
const step = pendingSteps[currentIndex];
|
||||
|
||||
const complete = useCallback(() => {
|
||||
onboardingStorage.setCompletedVersion(CURRENT_ONBOARDING_VERSION);
|
||||
navigation.navigate('Home' as never);
|
||||
}, [navigation]);
|
||||
|
||||
const handlePickDirectory = useCallback(async () => {
|
||||
const { safDirectory } = await import('../services/safDirectory');
|
||||
const FileSystem = await import('expo-file-system/legacy');
|
||||
|
||||
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');
|
||||
safDirectory.addFolder(dirUri, dirName);
|
||||
} catch (err) {
|
||||
console.error('[Onboarding] pickDirectory error:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
if (!step) return;
|
||||
onboardingStorage.markStepSeen(step.id);
|
||||
|
||||
if (currentIndex < pendingSteps.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
} else {
|
||||
complete();
|
||||
}
|
||||
}, [step, currentIndex, pendingSteps.length, complete]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
complete();
|
||||
}, [complete]);
|
||||
|
||||
if (!step) {
|
||||
complete();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.skipContainer}>
|
||||
<TouchableOpacity onPress={handleSkip}>
|
||||
<Text style={styles.skipText}>Passer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialIcons
|
||||
name={ICON_MAP[step.icon] ?? 'info'}
|
||||
size={64}
|
||||
color="#1976D2"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{step.title}</Text>
|
||||
<Text style={styles.description}>{step.description}</Text>
|
||||
|
||||
{step.action?.type === 'pick_directory' && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handlePickDirectory}>
|
||||
<MaterialIcons name="folder-open" size={20} color="#fff" />
|
||||
<Text style={styles.actionBtnText}>{step.action.label}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<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',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 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,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F57C00',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
gap: 10,
|
||||
marginTop: 32,
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user